Merge pull request #127 from bnema/refactor/interactive-mode-files
refactor(modes): split interactive responsibilities
15 files · +8257 -8227
M packages/agent/modes/interactive.go +160 -8227
| @@ -3,19 +3,11 @@ package modes | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | - | "errors" | |
| 7 | 6 | "fmt" | |
| 8 | - | "os" | |
| 9 | - | "path/filepath" | |
| 10 | - | "runtime" | |
| 11 | - | "sort" | |
| 12 | - | "strconv" | |
| 13 | 7 | "strings" | |
| 14 | 8 | "sync" | |
| 15 | 9 | "sync/atomic" | |
| 16 | 10 | "time" | |
| 17 | - | "unicode" | |
| 18 | - | "unicode/utf8" | |
| 19 | 11 | ||
| 20 | 12 | "github.com/bnema/zut/packages/agent/extensions" | |
| 21 | 13 | "github.com/bnema/zut/packages/agent/extproto" | |
| @@ -1377,143 +1369,6 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1377 | 1369 | } | |
| 1378 | 1370 | } | |
| 1379 | 1371 | ||
| 1380 | - | func (i *Interactive) requestRendererClear() { | |
| 1381 | - | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |
| 1382 | - | scheduler.request(true, false) | |
| 1383 | - | return | |
| 1384 | - | } | |
| 1385 | - | if i.rend != nil { | |
| 1386 | - | i.rend.Clear() | |
| 1387 | - | } | |
| 1388 | - | } | |
| 1389 | - | ||
| 1390 | - | func (i *Interactive) requestRendererInvalidate() { | |
| 1391 | - | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |
| 1392 | - | scheduler.request(false, true) | |
| 1393 | - | return | |
| 1394 | - | } | |
| 1395 | - | if i.rend != nil { | |
| 1396 | - | i.rend.Invalidate() | |
| 1397 | - | } | |
| 1398 | - | } | |
| 1399 | - | ||
| 1400 | - | func (i *Interactive) requestRendererTheme(theme tui.Theme) { | |
| 1401 | - | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |
| 1402 | - | scheduler.requestTheme(theme) | |
| 1403 | - | return | |
| 1404 | - | } | |
| 1405 | - | if i.rend != nil { | |
| 1406 | - | i.rend.SetTheme(theme) | |
| 1407 | - | } | |
| 1408 | - | } | |
| 1409 | - | ||
| 1410 | - | func (i *Interactive) invalidate() { | |
| 1411 | - | i.renderRevision.Add(1) | |
| 1412 | - | // Keep ordinary state changes on the main-loop throttle. The scheduler | |
| 1413 | - | // owns terminal output, but scheduling it here would bypass | |
| 1414 | - | // redrawMinInterval and paint every intermediate tool-event frame. | |
| 1415 | - | select { | |
| 1416 | - | case i.dirty <- struct{}{}: | |
| 1417 | - | default: | |
| 1418 | - | } | |
| 1419 | - | } | |
| 1420 | - | ||
| 1421 | - | func (i *Interactive) cachedChatLocked(cols int) []string { | |
| 1422 | - | // A busy frame contains mutable streaming/tool state. Keep the stable | |
| 1423 | - | // transcript cache, but never return a full-frame cache entry that would | |
| 1424 | - | // hide a newly arrived delta or completed result. | |
| 1425 | - | if i.busy || i.streamOn || i.streamFlushPending { | |
| 1426 | - | return i.buildChatLocked(cols) | |
| 1427 | - | } | |
| 1428 | - | key := i.chatCacheKeyLocked(cols) | |
| 1429 | - | if i.chatCacheValid && i.chatCacheKey == key { | |
| 1430 | - | return append([]string(nil), i.chatCache...) | |
| 1431 | - | } | |
| 1432 | - | chat := i.buildChatLocked(cols) | |
| 1433 | - | key = i.chatCacheKeyLocked(cols) | |
| 1434 | - | i.chatCache = append(i.chatCache[:0], chat...) | |
| 1435 | - | i.chatCacheKey = key | |
| 1436 | - | i.chatCacheValid = true | |
| 1437 | - | return chat | |
| 1438 | - | } | |
| 1439 | - | ||
| 1440 | - | func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey { | |
| 1441 | - | var rev uint64 | |
| 1442 | - | if i.agent != nil { | |
| 1443 | - | rev = i.agent.Revision() | |
| 1444 | - | } | |
| 1445 | - | showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration | |
| 1446 | - | return chatCacheKey{ | |
| 1447 | - | cols: cols, | |
| 1448 | - | agentRev: rev, | |
| 1449 | - | statusOK: i.statusOK, | |
| 1450 | - | statusErr: i.statusErr, | |
| 1451 | - | help: strings.Join(i.helpBlock, "\n"), | |
| 1452 | - | extNotes: strings.Join(i.extNotes, "\n"), | |
| 1453 | - | extStatuses: i.extensionStatusesKeyLocked(), | |
| 1454 | - | extWidgets: i.extensionWidgetsKeyLocked(), | |
| 1455 | - | reloadErrors: strings.Join(i.reloadErrors, "\n"), | |
| 1456 | - | updateAvailable: i.updateInfo.Available, | |
| 1457 | - | updateCurrent: i.updateInfo.Current, | |
| 1458 | - | updateLatest: i.updateInfo.Latest, | |
| 1459 | - | updateURL: i.updateInfo.URL, | |
| 1460 | - | welcomeShowVer: showVer, | |
| 1461 | - | expandAll: i.view.ExpandAll, | |
| 1462 | - | tailLimit: i.view.TailLimit, | |
| 1463 | - | renderedMessageCount: len(i.view.Messages), | |
| 1464 | - | viewCacheRev: i.view.RenderCacheRevision, | |
| 1465 | - | } | |
| 1466 | - | } | |
| 1467 | - | ||
| 1468 | - | func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string { | |
| 1469 | - | keys := make([]string, 0, len(groups)) | |
| 1470 | - | for key := range groups { | |
| 1471 | - | keys = append(keys, key) | |
| 1472 | - | } | |
| 1473 | - | sort.Strings(keys) | |
| 1474 | - | return keys | |
| 1475 | - | } | |
| 1476 | - | ||
| 1477 | - | func sortedNestedInnerKeys[T any](groups map[string]map[string]T, outer string) []string { | |
| 1478 | - | items := groups[outer] | |
| 1479 | - | keys := make([]string, 0, len(items)) | |
| 1480 | - | for key := range items { | |
| 1481 | - | keys = append(keys, key) | |
| 1482 | - | } | |
| 1483 | - | sort.Strings(keys) | |
| 1484 | - | return keys | |
| 1485 | - | } | |
| 1486 | - | ||
| 1487 | - | func (i *Interactive) extensionStatusesKeyLocked() string { | |
| 1488 | - | var sb strings.Builder | |
| 1489 | - | extNames := sortedNestedOuterKeys(i.extStatuses) | |
| 1490 | - | for _, extName := range extNames { | |
| 1491 | - | keys := sortedNestedInnerKeys(i.extStatuses, extName) | |
| 1492 | - | for _, key := range keys { | |
| 1493 | - | status := i.extStatuses[extName][key] | |
| 1494 | - | fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, key, status.Level, status.Text) | |
| 1495 | - | } | |
| 1496 | - | } | |
| 1497 | - | return sb.String() | |
| 1498 | - | } | |
| 1499 | - | ||
| 1500 | - | func (i *Interactive) extensionWidgetsKeyLocked() string { | |
| 1501 | - | var sb strings.Builder | |
| 1502 | - | extNames := sortedNestedOuterKeys(i.extWidgets) | |
| 1503 | - | for _, extName := range extNames { | |
| 1504 | - | ids := sortedNestedInnerKeys(i.extWidgets, extName) | |
| 1505 | - | for _, id := range ids { | |
| 1506 | - | widget := i.extWidgets[extName][id] | |
| 1507 | - | fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, id, widget.Position, widget.Title) | |
| 1508 | - | for _, line := range widget.Lines { | |
| 1509 | - | sb.WriteString(line) | |
| 1510 | - | sb.WriteByte('\n') | |
| 1511 | - | } | |
| 1512 | - | } | |
| 1513 | - | } | |
| 1514 | - | return sb.String() | |
| 1515 | - | } | |
| 1516 | - | ||
| 1517 | 1372 | const maxExtensionWidgetRows = 12 | |
| 1518 | 1373 | const maxExtensionStatusRows = 6 | |
| 1519 | 1374 | ||
| @@ -1521,360 +1376,17 @@ const maxExtensionStatusRows = 6 | |||
| 1521 | 1376 | // above the input, leaving one row for the truncation marker. | |
| 1522 | 1377 | const maxNarrowExtensionChromeRows = 6 | |
| 1523 | 1378 | ||
| 1524 | - | func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget { | |
| 1525 | - | var out []tui.RightBarWidget | |
| 1526 | - | for _, extName := range sortedNestedOuterKeys(i.extWidgets) { | |
| 1527 | - | for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) { | |
| 1528 | - | widget := i.extWidgets[extName][id] | |
| 1529 | - | if extproto.NormalizeWidgetPosition(widget.Position) != extproto.WidgetPositionRightBar { | |
| 1530 | - | continue | |
| 1531 | - | } | |
| 1532 | - | out = append(out, tui.RightBarWidget{ | |
| 1533 | - | Extension: extName, | |
| 1534 | - | ID: id, | |
| 1535 | - | Title: widget.Title, | |
| 1536 | - | Lines: append([]string(nil), widget.Lines...), | |
| 1537 | - | }) | |
| 1538 | - | } | |
| 1539 | - | } | |
| 1540 | - | return out | |
| 1541 | - | } | |
| 1542 | - | ||
| 1543 | - | func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | |
| 1544 | - | return i.extensionChromeLinesAtLocked(cols, false) | |
| 1545 | - | } | |
| 1546 | - | ||
| 1547 | - | func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActive, rightBarFallback bool) []string { | |
| 1548 | - | lines := i.extensionChromeLinesAtLocked(cols, rightBarActive) | |
| 1549 | - | if !rightBarFallback || len(lines) <= maxNarrowExtensionChromeRows { | |
| 1550 | - | return lines | |
| 1551 | - | } | |
| 1552 | - | limit := maxNarrowExtensionChromeRows | |
| 1553 | - | trimmed := append([]string(nil), lines[:limit-1]...) | |
| 1554 | - | trimmed = append(trimmed, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols))) | |
| 1555 | - | return trimmed | |
| 1556 | - | } | |
| 1557 | - | ||
| 1558 | - | func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string { | |
| 1559 | - | var out []string | |
| 1560 | - | bodyWidth := cols - 2 | |
| 1561 | - | if bodyWidth < 1 { | |
| 1562 | - | bodyWidth = 1 | |
| 1563 | - | } | |
| 1564 | - | widgetRows := 0 | |
| 1565 | - | widgetTruncated := false | |
| 1566 | - | appendWidgetRow := func(line string) bool { | |
| 1567 | - | if widgetRows >= maxExtensionWidgetRows-1 { | |
| 1568 | - | widgetTruncated = true | |
| 1569 | - | return false | |
| 1570 | - | } | |
| 1571 | - | out = append(out, line) | |
| 1572 | - | widgetRows++ | |
| 1573 | - | return true | |
| 1574 | - | } | |
| 1575 | - | ||
| 1576 | - | stopWidgets := false | |
| 1577 | - | for _, extName := range sortedNestedOuterKeys(i.extWidgets) { | |
| 1578 | - | if stopWidgets { | |
| 1579 | - | break | |
| 1580 | - | } | |
| 1581 | - | for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) { | |
| 1582 | - | widget := i.extWidgets[extName][id] | |
| 1583 | - | if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar { | |
| 1584 | - | continue | |
| 1585 | - | } | |
| 1586 | - | label := " [" + extName + "]" | |
| 1587 | - | if widget.Title != "" { | |
| 1588 | - | label += " " + widget.Title | |
| 1589 | - | } | |
| 1590 | - | if !appendWidgetRow(i.cfg.Theme.FGColor(i.cfg.Theme.Accent, truncateLine(label, cols))) { | |
| 1591 | - | stopWidgets = true | |
| 1592 | - | break | |
| 1593 | - | } | |
| 1594 | - | for _, line := range widget.Lines { | |
| 1595 | - | if !appendWidgetRow(" " + truncateLine(line, bodyWidth)) { | |
| 1596 | - | stopWidgets = true | |
| 1597 | - | break | |
| 1598 | - | } | |
| 1599 | - | } | |
| 1600 | - | if stopWidgets { | |
| 1601 | - | break | |
| 1602 | - | } | |
| 1603 | - | } | |
| 1604 | - | } | |
| 1605 | - | if widgetTruncated { | |
| 1606 | - | out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension widgets truncated ...", cols))) | |
| 1607 | - | } | |
| 1608 | - | ||
| 1609 | - | statusRows := 0 | |
| 1610 | - | statusTruncated := false | |
| 1611 | - | for _, extName := range sortedNestedOuterKeys(i.extStatuses) { | |
| 1612 | - | for _, key := range sortedNestedInnerKeys(i.extStatuses, extName) { | |
| 1613 | - | if statusRows >= maxExtensionStatusRows-1 { | |
| 1614 | - | statusTruncated = true | |
| 1615 | - | break | |
| 1616 | - | } | |
| 1617 | - | status := i.extStatuses[extName][key] | |
| 1618 | - | color := i.cfg.Theme.Muted | |
| 1619 | - | switch status.Level { | |
| 1620 | - | case "warn": | |
| 1621 | - | color = i.cfg.Theme.Warning | |
| 1622 | - | case "error": | |
| 1623 | - | color = i.cfg.Theme.Error | |
| 1624 | - | case "success": | |
| 1625 | - | color = i.cfg.Theme.Tool | |
| 1626 | - | } | |
| 1627 | - | label := " [" + extName + "] " + status.Text | |
| 1628 | - | out = append(out, i.cfg.Theme.FGColor(color, truncateLine(label, cols))) | |
| 1629 | - | statusRows++ | |
| 1630 | - | } | |
| 1631 | - | if statusTruncated { | |
| 1632 | - | break | |
| 1633 | - | } | |
| 1634 | - | } | |
| 1635 | - | if statusTruncated { | |
| 1636 | - | out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension statuses truncated ...", cols))) | |
| 1637 | - | } | |
| 1638 | - | return out | |
| 1639 | - | } | |
| 1640 | - | ||
| 1641 | - | func (i *Interactive) stableChatRowsLocked(cols int) []string { | |
| 1642 | - | key := i.chatCacheKeyLocked(cols) | |
| 1643 | - | if i.stableChatCacheValid && i.stableChatCacheKey == key { | |
| 1644 | - | return append([]string(nil), i.stableChatCache...) | |
| 1645 | - | } | |
| 1646 | - | ||
| 1647 | - | renderView := i.view.CloneForRender() | |
| 1648 | - | renderView.Streaming = "" | |
| 1649 | - | renderView.StreamingActive = false | |
| 1650 | - | renderView.ToolCalls = nil | |
| 1651 | - | renderView.Err = "" | |
| 1652 | - | if !i.renderOutsideLock { | |
| 1653 | - | rows := renderView.Build(cols) | |
| 1654 | - | i.stableChatCache = append(i.stableChatCache[:0], rows...) | |
| 1655 | - | i.stableChatCacheKey = key | |
| 1656 | - | i.stableChatCacheValid = true | |
| 1657 | - | return append([]string(nil), rows...) | |
| 1658 | - | } | |
| 1659 | - | // Markdown, syntax highlighting, and wrapping are the expensive part of | |
| 1660 | - | // a frame. Build the immutable snapshot without holding the interactive | |
| 1661 | - | // mutex so key processing can continue while a cold transcript renders. | |
| 1662 | - | i.mu.Unlock() | |
| 1663 | - | rows := renderView.Build(cols) | |
| 1664 | - | i.mu.Lock() | |
| 1665 | - | if i.view.MessagesRevision == renderView.MessagesRevision && | |
| 1666 | - | i.view.RenderCacheRevision == renderView.RenderCacheRevision { | |
| 1667 | - | i.view.AdoptRenderCacheFrom(renderView) | |
| 1668 | - | } | |
| 1669 | - | ||
| 1670 | - | i.stableChatCache = append(i.stableChatCache[:0], rows...) | |
| 1671 | - | i.stableChatCacheKey = key | |
| 1672 | - | i.stableChatCacheValid = true | |
| 1673 | - | return append([]string(nil), rows...) | |
| 1674 | - | } | |
| 1675 | - | ||
| 1676 | - | func (i *Interactive) buildChatLocked(cols int) []string { | |
| 1677 | - | if i.agent != nil { | |
| 1678 | - | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |
| 1679 | - | i.view.MessagesRevision = i.agent.Revision() | |
| 1680 | - | } else { | |
| 1681 | - | i.view.Messages = nil | |
| 1682 | - | i.view.MessagesRevision = 0 | |
| 1683 | - | } | |
| 1684 | - | // Pacer flush: while the streaming pacer is still draining the | |
| 1685 | - | // buffer (i.e. EvAssistantMessage already fired but more runes | |
| 1686 | - | // are queued), the final assistant message is already in | |
| 1687 | - | // i.agent.Messages() in full. Painting it in the transcript | |
| 1688 | - | // AND the streaming block at the same time shows the user the | |
| 1689 | - | // complete text immediately — which defeats the whole pacer. | |
| 1690 | - | // Hide the last message until the pacer catches up; once the | |
| 1691 | - | // flush-pending latch clears, the message is revealed (the | |
| 1692 | - | // streaming block disappears the same frame because streamOn | |
| 1693 | - | // flips off, so the transition is seamless). | |
| 1694 | - | if i.streamFlushPending && len(i.view.Messages) > 0 { | |
| 1695 | - | i.view.Messages = i.view.Messages[:len(i.view.Messages)-1] | |
| 1696 | - | } | |
| 1697 | - | i.view.Streaming = i.streaming.String() | |
| 1698 | - | i.view.StreamingActive = i.streamOn | |
| 1699 | - | // Guard against the narrow race where EvAssistantMessage has | |
| 1700 | - | // just promoted a streaming reply into the transcript but a | |
| 1701 | - | // render tick hasn't flipped streamOn off yet. Without the | |
| 1702 | - | // guard, the same text would appear twice (once as the | |
| 1703 | - | // in-flight streaming block, once as the last transcript | |
| 1704 | - | // message). We detect the duplicate strictly: the last | |
| 1705 | - | // assistant message's visible text must equal the streaming | |
| 1706 | - | // buffer. Just matching on role is too broad — it also hides | |
| 1707 | - | // the next round's typewriter streaming after a tool turn, | |
| 1708 | - | // because the last transcript message is always assistant | |
| 1709 | - | // (the tool-use block) until the follow-up summary lands. | |
| 1710 | - | if i.streamOn && i.streaming.Len() > 0 { | |
| 1711 | - | if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant { | |
| 1712 | - | if assistantText(i.view.Messages[n-1]) == i.streaming.String() { | |
| 1713 | - | i.view.StreamingActive = false | |
| 1714 | - | } | |
| 1715 | - | } | |
| 1716 | - | } | |
| 1717 | - | // Live tool-call view: only shown while a turn is in flight. Once | |
| 1718 | - | // the agent is idle, every tool call has already been folded into | |
| 1719 | - | // the transcript (as assistant.ToolCallBlock + a tool-role message), | |
| 1720 | - | // so showing v.ToolCalls a second time would duplicate them below | |
| 1721 | - | // the final assistant text — which looks like the summary came | |
| 1722 | - | // "before" the tools. | |
| 1723 | - | i.view.ToolCalls = i.view.ToolCalls[:0] | |
| 1724 | - | if i.busy { | |
| 1725 | - | for _, id := range i.toolOrder { | |
| 1726 | - | // Deterministic ordering: a tool block stays hidden until | |
| 1727 | - | // the paced assistant text that preceded it has finished | |
| 1728 | - | // typing out. toolOrder is append-only in arrival order, | |
| 1729 | - | // so once one tool is still gated, every later tool is too, | |
| 1730 | - | // so stop here to avoid showing a tool out of sequence. | |
| 1731 | - | if !i.toolGateOpenLocked(id) { | |
| 1732 | - | break | |
| 1733 | - | } | |
| 1734 | - | if tc, ok := i.toolCalls[id]; ok { | |
| 1735 | - | i.view.ToolCalls = append(i.view.ToolCalls, *tc) | |
| 1736 | - | } | |
| 1737 | - | } | |
| 1738 | - | } | |
| 1739 | - | i.view.Err = i.statusErr | |
| 1740 | - | // Live streaming/tool rows are appended to the chat buffer (not | |
| 1741 | - | // hoisted into a separate live block above the editor). That keeps | |
| 1742 | - | // the renderer's diff view append-only: when a tool finishes the | |
| 1743 | - | // rows update in place at the end of the buffer, instead of the | |
| 1744 | - | // whole bottom band shrinking and shifting chat lines around. | |
| 1745 | - | i.liveBlock = nil | |
| 1746 | - | stable := i.stableChatRowsLocked(cols) | |
| 1747 | - | chat := append([]string(nil), stable...) | |
| 1748 | - | chat = append(chat, i.view.BuildLive(cols)...) | |
| 1749 | - | ||
| 1750 | - | // Welcome banner: shown at the top of the chat area when there is | |
| 1751 | - | // no transcript yet. Disappears after the first message is sent. | |
| 1752 | - | // The version suffix is shown for welcomeVersionDuration after | |
| 1753 | - | // startup, then drops off automatically. | |
| 1754 | - | if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 { | |
| 1755 | - | showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration | |
| 1756 | - | chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...) | |
| 1757 | - | } | |
| 1758 | - | ||
| 1759 | - | // Update-available banner: prepended above everything else so it's | |
| 1760 | - | // the first thing the user sees when opening a new zut session. | |
| 1761 | - | // Once rendered, it stays until the user updates to a newer | |
| 1762 | - | // version — we don't persist a "dismissed" flag because this is | |
| 1763 | - | // cheap and re-showing it is how most users remember to update. | |
| 1764 | - | if i.updateInfo.Available { | |
| 1765 | - | banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols) | |
| 1766 | - | chat = append(banner, chat...) | |
| 1767 | - | } | |
| 1768 | - | ||
| 1769 | - | // /help block: appended to the transcript so it appears at the | |
| 1770 | - | // bottom of the chat area (right above the status bar / editor). | |
| 1771 | - | // Prepending it would push long conversations off the top of the | |
| 1772 | - | // viewport, which users would miss entirely. | |
| 1773 | - | if len(i.helpBlock) > 0 { | |
| 1774 | - | chat = append(chat, i.helpBlock...) | |
| 1775 | - | } | |
| 1776 | - | ||
| 1777 | - | if i.statusOK != "" { | |
| 1778 | - | // Hard-truncate the OK line to the visible width so a long | |
| 1779 | - | // session path ("resumed session: /Users/.../sessions/...") | |
| 1780 | - | // doesn't overflow past the right edge and look broken on a | |
| 1781 | - | // narrow terminal. | |
| 1782 | - | line := "✓ " + i.statusOK | |
| 1783 | - | if cols > 4 && len(line) > cols { | |
| 1784 | - | line = line[:cols-3] + "..." | |
| 1785 | - | } | |
| 1786 | - | chat = append(chat, i.cfg.Theme.FGColor(i.cfg.Theme.Tool, line), "") | |
| 1787 | - | } | |
| 1788 | - | ||
| 1789 | - | // Live shell-escape output (!command / entry.pre) streams into the | |
| 1790 | - | // transcript area while the command runs, then is replaced by the | |
| 1791 | - | // final user-context message when it finishes. | |
| 1792 | - | if i.shellRunning && i.shellLive != "" { | |
| 1793 | - | chat = append(chat, strings.Split(strings.TrimRight(i.shellLive, "\n"), "\n")...) | |
| 1794 | - | chat = append(chat, "") | |
| 1795 | - | } | |
| 1796 | - | ||
| 1797 | - | // Extension notes (notify / display) live just under the | |
| 1798 | - | // transcript, above the dialog/editor band. Cleared by /clear. | |
| 1799 | - | if len(i.extNotes) > 0 { | |
| 1800 | - | chat = append(chat, i.extNotes...) | |
| 1801 | - | chat = append(chat, "") | |
| 1802 | - | } | |
| 1803 | - | ||
| 1804 | - | // Reload failures remain in the scrolling chat until /clear. They are | |
| 1805 | - | // host-only display state, not provider messages or persisted context. | |
| 1806 | - | // While the latest failure is still the temporary status error, hide its | |
| 1807 | - | // stored copy to avoid painting the same message twice. | |
| 1808 | - | reloadErrors := i.reloadErrors | |
| 1809 | - | if n := len(reloadErrors); n > 0 && reloadErrors[n-1] == i.statusErr { | |
| 1810 | - | reloadErrors = reloadErrors[:n-1] | |
| 1811 | - | } | |
| 1812 | - | if len(reloadErrors) > 0 { | |
| 1813 | - | chat = append(chat, renderReloadErrors(i.cfg.Theme, reloadErrors, cols)...) | |
| 1814 | - | chat = append(chat, "") | |
| 1815 | - | } | |
| 1816 | - | ||
| 1817 | - | // Strip trailing blank rows so the chat content sits flush | |
| 1818 | - | // against the new "blank above status bar" row added by the | |
| 1819 | - | // bottom-region assembly. Build() ends every message with a | |
| 1820 | - | // blank separator; without this trim, the final message in | |
| 1821 | - | // the transcript would have its own trailing blank plus the | |
| 1822 | - | // status block's leading blank, doubling the gap. | |
| 1823 | - | for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" { | |
| 1824 | - | chat = chat[:len(chat)-1] | |
| 1825 | - | } | |
| 1826 | - | return chat | |
| 1827 | - | } | |
| 1828 | - | ||
| 1829 | 1379 | // sessionsRoot returns the session namespace for this run. | |
| 1830 | - | func (i *Interactive) sessionsRoot() string { | |
| 1831 | - | if i.cfg.SessionsRoot != "" { | |
| 1832 | - | return i.cfg.SessionsRoot | |
| 1833 | - | } | |
| 1834 | - | return i.cfg.ZutHome | |
| 1835 | - | } | |
| 1836 | 1380 | ||
| 1837 | 1381 | // lastCols returns the current terminal width in columns. | |
| 1838 | - | func (i *Interactive) lastCols() int { | |
| 1839 | - | cols, _ := i.cfg.Terminal.Size() | |
| 1840 | - | return cols | |
| 1841 | - | } | |
| 1842 | 1382 | ||
| 1843 | 1383 | // chatPage returns the number of chat rows currently visible, used | |
| 1844 | 1384 | // as the page size for PageUp/PageDown. | |
| 1845 | - | func (i *Interactive) chatPage() int { | |
| 1846 | - | _, rows := i.cfg.Terminal.Size() | |
| 1847 | - | p := rows - 6 // rough reservation for status + editor + a dialog line | |
| 1848 | - | if p < 4 { | |
| 1849 | - | p = 4 | |
| 1850 | - | } | |
| 1851 | - | return p | |
| 1852 | - | } | |
| 1853 | 1385 | ||
| 1854 | 1386 | // scrollBy adjusts the scroll offset. Positive = up (into history). | |
| 1855 | 1387 | // Clearing the parked-turn label when we're back at the bottom means | |
| 1856 | 1388 | // the "viewing turn N" footer goes away automatically as soon as you | |
| 1857 | 1389 | // scroll back to the live tail. | |
| 1858 | - | func (i *Interactive) scrollBy(delta int) { | |
| 1859 | - | i.mu.Lock() | |
| 1860 | - | i.scrollOffset += delta | |
| 1861 | - | if i.scrollOffset < 0 { | |
| 1862 | - | i.scrollOffset = 0 | |
| 1863 | - | } | |
| 1864 | - | if i.scrollOffset == 0 { | |
| 1865 | - | i.parkedTurn = 0 | |
| 1866 | - | i.parkedTotal = 0 | |
| 1867 | - | } | |
| 1868 | - | if i.rend != nil { | |
| 1869 | - | // VS Code's terminal is especially prone to leaving stray | |
| 1870 | - | // wrapped-character fragments behind during scroll-driven | |
| 1871 | - | // viewport changes. Force a full repaint on scroll, but | |
| 1872 | - | // avoid a whole-screen clear because that visibly flickers. | |
| 1873 | - | i.requestRendererInvalidate() | |
| 1874 | - | } | |
| 1875 | - | i.mu.Unlock() | |
| 1876 | - | i.invalidate() | |
| 1877 | - | } | |
| 1878 | 1390 | ||
| 1879 | 1391 | // anchorScrollOffset keeps the user's reading position pinned when the | |
| 1880 | 1392 | // chat buffer grows/shrinks or the viewport height changes between two | |
| @@ -1885,637 +1397,8 @@ func (i *Interactive) scrollBy(delta int) { | |||
| 1885 | 1397 | // `start` constant we adjust the offset by the buffer-length delta minus | |
| 1886 | 1398 | // the viewport-height delta. The result is clamped to [0, newLen] so a | |
| 1887 | 1399 | // shrinking buffer can't push it negative. | |
| 1888 | - | func anchorScrollOffset(offset, prevLen, newLen, prevRows, newRows int) int { | |
| 1889 | - | adj := (newLen - prevLen) - (newRows - prevRows) | |
| 1890 | - | offset += adj | |
| 1891 | - | if offset < 0 { | |
| 1892 | - | offset = 0 | |
| 1893 | - | } | |
| 1894 | - | if offset > newLen { | |
| 1895 | - | offset = newLen | |
| 1896 | - | } | |
| 1897 | - | return offset | |
| 1898 | - | } | |
| 1899 | 1400 | ||
| 1900 | 1401 | // scrollToBottom pins the view to the latest content. | |
| 1901 | - | func (i *Interactive) scrollToBottom() { | |
| 1902 | - | i.mu.Lock() | |
| 1903 | - | i.scrollOffset = 0 | |
| 1904 | - | i.parkedTurn = 0 | |
| 1905 | - | i.parkedTotal = 0 | |
| 1906 | - | // Reset the auto-follow baseline. scrollToBottom is the resume / | |
| 1907 | - | // session-swap snap point, where the chat buffer changes length | |
| 1908 | - | // wholesale. Without zeroing these, the next render's follow guard | |
| 1909 | - | // compares the fresh transcript's length against a stale baseline | |
| 1910 | - | // and nudges scrollOffset, which reads as a viewport jump right | |
| 1911 | - | // after resume. See commit 43da5e5 for the same fix on new turns. | |
| 1912 | - | i.prevChatLen = 0 | |
| 1913 | - | i.prevChatCols = 0 | |
| 1914 | - | i.requestRendererInvalidate() | |
| 1915 | - | i.mu.Unlock() | |
| 1916 | - | i.invalidate() | |
| 1917 | - | } | |
| 1918 | - | ||
| 1919 | - | func (i *Interactive) redraw() { | |
| 1920 | - | i.mu.Lock() | |
| 1921 | - | renderRevision := i.renderRevision.Load() | |
| 1922 | - | ||
| 1923 | - | cols, rows := i.cfg.Terminal.Size() | |
| 1924 | - | mainCols := cols | |
| 1925 | - | rightBarWidgets := i.rightBarWidgetsLocked() | |
| 1926 | - | rightBarActive := false | |
| 1927 | - | var rightBarWidth int | |
| 1928 | - | if !i.rightBarHidden && len(rightBarWidgets) > 0 { | |
| 1929 | - | if width, rail, ok := tui.RightBarColumns(cols); ok { | |
| 1930 | - | mainCols = width | |
| 1931 | - | rightBarWidth = rail | |
| 1932 | - | rightBarActive = true | |
| 1933 | - | } | |
| 1934 | - | } | |
| 1935 | - | rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive | |
| 1936 | - | i.renderOutsideLock = true | |
| 1937 | - | chat := i.cachedChatLocked(mainCols) | |
| 1938 | - | i.renderOutsideLock = false | |
| 1939 | - | if i.renderRevision.Load() != renderRevision { | |
| 1940 | - | i.mu.Unlock() | |
| 1941 | - | return | |
| 1942 | - | } | |
| 1943 | - | ||
| 1944 | - | // Dialogs (login or model picker) render between chat and the editor. | |
| 1945 | - | var dialog []string | |
| 1946 | - | switch { | |
| 1947 | - | case i.dialog.Active(): | |
| 1948 | - | dialog = i.dialog.Render(i.cfg.Theme, mainCols) | |
| 1949 | - | case i.modelDialog.Active(): | |
| 1950 | - | dialog = i.modelDialog.Render(i.cfg.Theme, mainCols) | |
| 1951 | - | case i.llamaDialog.Active(): | |
| 1952 | - | dialog = i.llamaDialog.Render(i.cfg.Theme, mainCols) | |
| 1953 | - | case i.rescueDialog.Active(): | |
| 1954 | - | dialog = i.rescueDialog.Render(i.cfg.Theme, mainCols) | |
| 1955 | - | case i.sessionDialog.Active(): | |
| 1956 | - | // Reserve rows for the editor (~3), status line (1-2), | |
| 1957 | - | // dialog chrome (header + hint + rule + indicators, ~5), | |
| 1958 | - | // and leave the remainder for session rows. Minimum of 3 | |
| 1959 | - | // rows so even a very small terminal shows something. | |
| 1960 | - | _, rows := i.cfg.Terminal.Size() | |
| 1961 | - | avail := rows - 12 | |
| 1962 | - | if avail < 3 { | |
| 1963 | - | avail = 3 | |
| 1964 | - | } | |
| 1965 | - | i.sessionDialog.MaxRows = avail | |
| 1966 | - | dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols) | |
| 1967 | - | case i.residentSubagentsDialog.Active(): | |
| 1968 | - | _, terminalRows := i.cfg.Terminal.Size() | |
| 1969 | - | dialog = i.residentSubagentsDialog.Render(i.cfg.Theme, mainCols, terminalRows) | |
| 1970 | - | case i.residentChildSession != nil: | |
| 1971 | - | _, terminalRows := i.cfg.Terminal.Size() | |
| 1972 | - | dialog = i.residentChildSession.Render(mainCols, terminalRows) | |
| 1973 | - | case i.jumpDialog.Active(): | |
| 1974 | - | dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols) | |
| 1975 | - | case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()): | |
| 1976 | - | dialog = i.extPanel.Render(i.cfg.Theme, mainCols) | |
| 1977 | - | case i.confirmDialog.Active(): | |
| 1978 | - | if i.btwDialog.Active() { | |
| 1979 | - | // Keep the side-chat transcript and its live tool preview visible | |
| 1980 | - | // above confirmation, matching how the main transcript remains | |
| 1981 | - | // visible while --no-yolo waits for a decision. | |
| 1982 | - | dialog = renderBtwConfirmation(i.cfg.Theme, mainCols, i.btwDialog, i.confirmDialog) | |
| 1983 | - | } else { | |
| 1984 | - | dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols) | |
| 1985 | - | } | |
| 1986 | - | case i.btwDialog.Active(): | |
| 1987 | - | dialog = i.btwDialog.Render(i.cfg.Theme, mainCols) | |
| 1988 | - | case i.skillsDialog.Active(): | |
| 1989 | - | dialog = i.skillsDialog.Render(i.cfg.Theme, mainCols) | |
| 1990 | - | case i.changelogDialog.Active(): | |
| 1991 | - | dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols) | |
| 1992 | - | case i.logoutDialog.Active(): | |
| 1993 | - | dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols) | |
| 1994 | - | case i.telegramDialog.Active(): | |
| 1995 | - | dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols) | |
| 1996 | - | case i.settingsDialog.Active(): | |
| 1997 | - | dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols) | |
| 1998 | - | case i.sessionOpsDialog.Active(): | |
| 1999 | - | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols) | |
| 2000 | - | case i.sessionTreeDialog.Active(): | |
| 2001 | - | // Reserve rows for the editor, status, and tree chrome while using | |
| 2002 | - | // the same budget for rendering and PageUp/PageDown movement. | |
| 2003 | - | _, rows := i.cfg.Terminal.Size() | |
| 2004 | - | avail := rows - 12 | |
| 2005 | - | if avail < 3 { | |
| 2006 | - | avail = 3 | |
| 2007 | - | } | |
| 2008 | - | i.sessionTreeDialog.MaxRows = avail | |
| 2009 | - | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols) | |
| 2010 | - | } | |
| 2011 | - | if len(dialog) > 0 { | |
| 2012 | - | dialog = padDialogFrame(dialog) | |
| 2013 | - | } | |
| 2014 | - | modalBackdrop := len(dialog) > 0 | |
| 2015 | - | ||
| 2016 | - | // Slash-command autocomplete: popup above the status line, only | |
| 2017 | - | // when the editor starts with "/" and no dialog is already open. | |
| 2018 | - | // Feed extension-registered commands into the suggester first so | |
| 2019 | - | // they show up in tab-complete + the popup alongside the built-ins. | |
| 2020 | - | i.suggest.SetJailed(i.cfg.Sandbox.Locked()) | |
| 2021 | - | i.suggest.SetLlamaConfigured(i.llamaConfigured) | |
| 2022 | - | if i.cfg.Extensions != nil { | |
| 2023 | - | catalog := i.cfg.Extensions.Commands() | |
| 2024 | - | extra := make([]slashCommand, 0, len(catalog)) | |
| 2025 | - | for _, c := range catalog { | |
| 2026 | - | // The popup renders extension commands under a dedicated | |
| 2027 | - | // "── extensions ───" divider, so the description doesn't | |
| 2028 | - | // need to repeat the source. If the description is empty, | |
| 2029 | - | // fall back to the extension name so the row isn't blank. | |
| 2030 | - | desc := c.Description | |
| 2031 | - | if strings.TrimSpace(desc) == "" { | |
| 2032 | - | desc = "(" + c.Extension + ")" | |
| 2033 | - | } | |
| 2034 | - | extra = append(extra, slashCommand{ | |
| 2035 | - | Name: "/" + c.Name, | |
| 2036 | - | Desc: desc, | |
| 2037 | - | }) | |
| 2038 | - | } | |
| 2039 | - | i.suggest.SetExtra(extra) | |
| 2040 | - | } | |
| 2041 | - | var suggest []string | |
| 2042 | - | currentInput := i.ed.Value() | |
| 2043 | - | if i.suggest.SkillInputStarted(currentInput) { | |
| 2044 | - | var list []*skills.Skill | |
| 2045 | - | if i.cfg.SkillSnapshot != nil { | |
| 2046 | - | list = i.cfg.SkillSnapshot() | |
| 2047 | - | } | |
| 2048 | - | i.suggest.SetSkills(list) | |
| 2049 | - | } | |
| 2050 | - | // Slash popup renders even while the agent is busy so the user | |
| 2051 | - | // can queue a destructive command (/clear, /compact, /logout, | |
| 2052 | - | // /model) or a read-only one (/help, /jump, /sessions, etc.) | |
| 2053 | - | // without waiting for the current turn to finish. The dispatcher | |
| 2054 | - | // in runSlash already handles the busy case per-command: safe | |
| 2055 | - | // ones run immediately, destructive ones cancel the turn first. | |
| 2056 | - | i.fileSuggest.SetCWD(i.cfg.CWD) | |
| 2057 | - | mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked()) | |
| 2058 | - | if mainInputFocused && i.suggest.Active(currentInput) { | |
| 2059 | - | suggest = i.suggest.Render(currentInput, i.cfg.Theme, mainCols) | |
| 2060 | - | } else if mainInputFocused && i.fileSuggest.Active(currentInput) { | |
| 2061 | - | suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, mainCols) | |
| 2062 | - | } | |
| 2063 | - | ||
| 2064 | - | // Detect overlay close (any dialog or slash/file suggestion popup | |
| 2065 | - | // just transitioned from open to closed). Force a full redraw so | |
| 2066 | - | // the rows the overlay occupied are guaranteed to be repainted | |
| 2067 | - | // from the chat below, instead of the diff path leaving stale | |
| 2068 | - | // dialog content behind. Equivalent to the user pressing ctrl+l. | |
| 2069 | - | overlayOpen := len(dialog) > 0 || len(suggest) > 0 | |
| 2070 | - | if i.rend != nil && i.prevOverlayOpen && !overlayOpen { | |
| 2071 | - | // An overlay (dialog or slash/file popup) just closed, so the | |
| 2072 | - | // bottom band shrinks. On terminals where we can drop | |
| 2073 | - | // scrollback, a full Clear is the simplest way to guarantee | |
| 2074 | - | // the vacated rows are repainted from the chat below. | |
| 2075 | - | // | |
| 2076 | - | // On VS Code's terminal closing a dialog leaves the stale | |
| 2077 | - | // overlay rows in the retained scrollback (we can't drop them | |
| 2078 | - | // with the quiet in-place diff). Run the same full Clear() that | |
| 2079 | - | // Ctrl+L uses so the scrollback is purged and the conversation | |
| 2080 | - | // is repainted clean, matching what the user expects after | |
| 2081 | - | // dismissing a picker. Clear() is keepScrollback-aware and | |
| 2082 | - | // emits \x1b[3J there. | |
| 2083 | - | i.rend.Clear() | |
| 2084 | - | } | |
| 2085 | - | i.prevOverlayOpen = overlayOpen | |
| 2086 | - | if len(suggest) > 0 { | |
| 2087 | - | // One blank row above the popup so it doesn't sit flush | |
| 2088 | - | // against the chat / welcome content above. | |
| 2089 | - | suggest = append([]string{""}, suggest...) | |
| 2090 | - | } | |
| 2091 | - | ||
| 2092 | - | // Busy prefix shown at the far left of the status bar. The spinner owns | |
| 2093 | - | // only its frame; the activity label reports the operation in progress. | |
| 2094 | - | var busyPrefix string | |
| 2095 | - | if i.busy { | |
| 2096 | - | label := i.activity.label() | |
| 2097 | - | if label == "" { | |
| 2098 | - | label = "Preparing request" | |
| 2099 | - | } | |
| 2100 | - | busyPrefix = fmt.Sprintf("%s %s %s %s", | |
| 2101 | - | i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()), | |
| 2102 | - | i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, label), | |
| 2103 | - | i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"), | |
| 2104 | - | i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()), | |
| 2105 | - | ) | |
| 2106 | - | } | |
| 2107 | - | ||
| 2108 | - | ctxMax := 0 | |
| 2109 | - | if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil { | |
| 2110 | - | ctxMax = m.ContextWindow | |
| 2111 | - | } | |
| 2112 | - | statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition) | |
| 2113 | - | workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition) | |
| 2114 | - | workingWithStatus := statusPosition == workingPosition | |
| 2115 | - | statusBusyPrefix := "" | |
| 2116 | - | if workingWithStatus { | |
| 2117 | - | statusBusyPrefix = busyPrefix | |
| 2118 | - | } | |
| 2119 | - | goalStatus := string(i.goalStatus) | |
| 2120 | - | statusLines := tui.StatusBar(tui.StatusBarParams{ | |
| 2121 | - | Theme: i.cfg.Theme, | |
| 2122 | - | Provider: i.cfg.Provider, | |
| 2123 | - | Model: i.cfg.Model, | |
| 2124 | - | Reasoning: i.cfg.Reasoning, | |
| 2125 | - | FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode, | |
| 2126 | - | Busy: i.busy, | |
| 2127 | - | BusyPrefix: statusBusyPrefix, | |
| 2128 | - | CWD: i.cfg.CWD, | |
| 2129 | - | Locked: i.cfg.Sandbox.Locked(), | |
| 2130 | - | NoYolo: i.cfg.NoYolo, | |
| 2131 | - | GoalStatus: goalStatus, | |
| 2132 | - | Usage: i.cumUsage, | |
| 2133 | - | Subscription: i.cfg.AuthMethod == "oauth", | |
| 2134 | - | ContextUsed: i.lastCtxInput, | |
| 2135 | - | ContextMax: ctxMax, | |
| 2136 | - | AutoCompacting: i.autoCompacting, | |
| 2137 | - | Telegram: i.telegramBridge != nil && i.telegramBridge.Active(), | |
| 2138 | - | Cols: mainCols, | |
| 2139 | - | }) | |
| 2140 | - | inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) | |
| 2141 | - | if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock { | |
| 2142 | - | i.ed.Prompt = "" | |
| 2143 | - | } else { | |
| 2144 | - | i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) | |
| 2145 | - | } | |
| 2146 | - | edLines, curR, curC := i.ed.Render(mainCols) | |
| 2147 | - | dashboardActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil | |
| 2148 | - | var allResidentSubagentLines []string | |
| 2149 | - | residentSubagentHidden := 0 | |
| 2150 | - | if !dashboardActive && i.cfg.ResidentManager != nil { | |
| 2151 | - | const residentIndicatorSnapshotLimit = 8 | |
| 2152 | - | snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit) | |
| 2153 | - | allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock()) | |
| 2154 | - | residentSubagentHidden = total - len(snapshots) | |
| 2155 | - | } | |
| 2156 | - | var workingLines []string | |
| 2157 | - | if busyPrefix != "" && !workingWithStatus { | |
| 2158 | - | workingLines = []string{" " + busyPrefix} | |
| 2159 | - | } | |
| 2160 | - | inputCursorOffset := 0 | |
| 2161 | - | inputCursorColOffset := 0 | |
| 2162 | - | switch inputStyle { | |
| 2163 | - | case tui.InputStyleLines: | |
| 2164 | - | edLines = tui.InputLines(i.cfg.Theme, edLines, mainCols) | |
| 2165 | - | inputCursorOffset = 1 | |
| 2166 | - | case tui.InputStyleBlock: | |
| 2167 | - | edLines = tui.InputBlock(i.cfg.Theme, edLines, mainCols) | |
| 2168 | - | inputCursorOffset = 1 | |
| 2169 | - | inputCursorColOffset = 2 | |
| 2170 | - | } | |
| 2171 | - | ||
| 2172 | - | // "Sliding in" chips for messages the user typed while a turn is | |
| 2173 | - | // in flight. Shown directly above the status bar so they're close | |
| 2174 | - | // to the editor but don't push the chat around. | |
| 2175 | - | var queue []string | |
| 2176 | - | queued := append([]string(nil), i.queued...) | |
| 2177 | - | if i.agent != nil { | |
| 2178 | - | queued = append(queued, i.agent.PendingQueuedMessages()...) | |
| 2179 | - | } | |
| 2180 | - | if len(queued) > 0 { | |
| 2181 | - | queue = append(queue, "") | |
| 2182 | - | for _, q := range queued { | |
| 2183 | - | label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ") | |
| 2184 | - | text := truncateLine(q, mainCols-17) | |
| 2185 | - | queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)) | |
| 2186 | - | } | |
| 2187 | - | // Hint row, rendered in the same muted tone as the model | |
| 2188 | - | // info on the status bar so it reads as ambient metadata | |
| 2189 | - | // rather than a chip. Tells the user how to recover the | |
| 2190 | - | // most recent queued message back into the editor. | |
| 2191 | - | hint := " Press " + slideBackChordHint() + " to slide back into input" | |
| 2192 | - | queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint)) | |
| 2193 | - | } | |
| 2194 | - | ||
| 2195 | - | extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback) | |
| 2196 | - | ||
| 2197 | - | // Bottom-sticky sections (always visible, never scroll). Each | |
| 2198 | - | // non-empty subsection (dialog, suggest popup, sliding-in queue) | |
| 2199 | - | // is preceded by one blank row so it has air above the chat | |
| 2200 | - | // content. The status block and editor get their own dedicated | |
| 2201 | - | // blanks so spacing stays consistent whether or not a dialog or | |
| 2202 | - | // popup is showing. | |
| 2203 | - | composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) { | |
| 2204 | - | bottom = make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9) | |
| 2205 | - | inputStartRow = -1 | |
| 2206 | - | if len(dialog) > 0 { | |
| 2207 | - | bottom = append(bottom, "") | |
| 2208 | - | } | |
| 2209 | - | bottom = append(bottom, dialog...) | |
| 2210 | - | // The resident child views own the bottom of the screen while active. | |
| 2211 | - | if dashboardActive { | |
| 2212 | - | return bottom, inputStartRow | |
| 2213 | - | } | |
| 2214 | - | ||
| 2215 | - | bottom = append(bottom, suggest...) | |
| 2216 | - | bottom = append(bottom, queue...) | |
| 2217 | - | lineInput := inputStyle == tui.InputStyleLines | |
| 2218 | - | statusBelow := statusPosition == tui.StatusPositionBelowInput | |
| 2219 | - | workingBelow := workingPosition == tui.WorkingPositionBelowInput | |
| 2220 | - | var aboveInput []string | |
| 2221 | - | aboveInput = append(aboveInput, extensionLines...) | |
| 2222 | - | if !statusBelow { | |
| 2223 | - | aboveInput = append(aboveInput, statusLines...) | |
| 2224 | - | } | |
| 2225 | - | if !workingBelow { | |
| 2226 | - | aboveInput = append(aboveInput, workingLines...) | |
| 2227 | - | } | |
| 2228 | - | var belowInput []string | |
| 2229 | - | // Background resident work belongs directly below the editor. It is | |
| 2230 | - | // deliberately independent of the main turn's busy/status placement. | |
| 2231 | - | belowInput = append(belowInput, residentSubagentLines...) | |
| 2232 | - | if workingBelow { | |
| 2233 | - | belowInput = append(belowInput, workingLines...) | |
| 2234 | - | } | |
| 2235 | - | if statusBelow { | |
| 2236 | - | belowInput = append(belowInput, statusLines...) | |
| 2237 | - | } | |
| 2238 | - | ||
| 2239 | - | bottom = append(bottom, "") | |
| 2240 | - | bottom = append(bottom, aboveInput...) | |
| 2241 | - | needsGapAboveInput := len(aboveInput) > 0 && !lineInput | |
| 2242 | - | if lineInput && statusBelow && !workingBelow && len(workingLines) > 0 { | |
| 2243 | - | needsGapAboveInput = true | |
| 2244 | - | } | |
| 2245 | - | if needsGapAboveInput { | |
| 2246 | - | bottom = append(bottom, "") | |
| 2247 | - | } | |
| 2248 | - | inputStartRow = len(bottom) | |
| 2249 | - | bottom = append(bottom, edLines...) | |
| 2250 | - | if len(belowInput) > 0 { | |
| 2251 | - | // Active resident work sits immediately below the prompt. Other | |
| 2252 | - | // below-input chrome keeps the usual breathing room. | |
| 2253 | - | if !lineInput && len(residentSubagentLines) == 0 { | |
| 2254 | - | bottom = append(bottom, "") | |
| 2255 | - | } | |
| 2256 | - | bottom = append(bottom, belowInput...) | |
| 2257 | - | } | |
| 2258 | - | return bottom, inputStartRow | |
| 2259 | - | } | |
| 2260 | - | ||
| 2261 | - | const rendererBottomMarginRows = 1 | |
| 2262 | - | maxBottomRows := rows - rendererBottomMarginRows - 1 | |
| 2263 | - | var residentSubagentLines []string | |
| 2264 | - | var bottom []string | |
| 2265 | - | var inputStartRow int | |
| 2266 | - | if !dashboardActive && len(allResidentSubagentLines) > 0 { | |
| 2267 | - | for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- { | |
| 2268 | - | candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols) | |
| 2269 | - | candidateBottom, _ := composeBottom(candidate) | |
| 2270 | - | if len(candidateBottom) <= maxBottomRows || maxRows == 0 { | |
| 2271 | - | residentSubagentLines = candidate | |
| 2272 | - | break | |
| 2273 | - | } | |
| 2274 | - | } | |
| 2275 | - | } | |
| 2276 | - | bottom, inputStartRow = composeBottom(residentSubagentLines) | |
| 2277 | - | ||
| 2278 | - | chatRows := rows - len(bottom) | |
| 2279 | - | if chatRows < 1 { | |
| 2280 | - | chatRows = 1 | |
| 2281 | - | } | |
| 2282 | - | ||
| 2283 | - | // Auto-follow guard: when the user has scrolled up (scrollOffset | |
| 2284 | - | // > 0) and the agent appends new content below the viewport while | |
| 2285 | - | // they're reading, compensate so the visible content stays | |
| 2286 | - | // anchored. scrollOffset is measured from the bottom of `chat`, | |
| 2287 | - | // so without compensation a growing buffer pushes the window | |
| 2288 | - | // downward through the content and the lines the user was | |
| 2289 | - | // reading scroll off the top. | |
| 2290 | - | // | |
| 2291 | - | // Skip compensation when the terminal width changed (a resize | |
| 2292 | - | // reflows the whole buffer and the line-count delta no longer | |
| 2293 | - | // corresponds to appended content) and when scrollOffset is 0 | |
| 2294 | - | // (the user is following the tail and wants new content to push | |
| 2295 | - | // the view down as usual). | |
| 2296 | - | // | |
| 2297 | - | // The window the user sees starts at row | |
| 2298 | - | // start = len(chat) - scrollOffset - chatRows | |
| 2299 | - | // so to keep `start` fixed across a redraw we must offset by both | |
| 2300 | - | // the buffer growth (len delta) AND the viewport-height change | |
| 2301 | - | // (chatRows delta, e.g. the status band or sliding-in queue | |
| 2302 | - | // appearing during a turn). Compensating only for the len delta | |
| 2303 | - | // let a shrinking chatRows pull the window toward the tail, which | |
| 2304 | - | // read as the viewport jumping to the bottom whenever the agent | |
| 2305 | - | // streamed text or a tool call grew the bottom band. | |
| 2306 | - | if i.scrollOffset > 0 && i.prevChatCols == mainCols && i.prevChatLen > 0 { | |
| 2307 | - | i.scrollOffset = anchorScrollOffset(i.scrollOffset, | |
| 2308 | - | i.prevChatLen, len(chat), i.prevChatRows, chatRows) | |
| 2309 | - | } | |
| 2310 | - | i.prevChatLen = len(chat) | |
| 2311 | - | i.prevChatCols = mainCols | |
| 2312 | - | i.prevChatRows = chatRows | |
| 2313 | - | ||
| 2314 | - | // Apply scroll offset to the chat slice. | |
| 2315 | - | maxOffset := len(chat) - chatRows | |
| 2316 | - | if maxOffset < 0 { | |
| 2317 | - | maxOffset = 0 | |
| 2318 | - | } | |
| 2319 | - | // Tail-render expansion: if the user has scrolled to (or above) | |
| 2320 | - | // the top of the currently rendered tail and there are still | |
| 2321 | - | // truncated messages above, widen view.TailLimit and rebuild. | |
| 2322 | - | // The chat cache is keyed on tailLimit so the next cachedChatLocked | |
| 2323 | - | // will re-issue Build instead of returning the stale slice. We | |
| 2324 | - | // rebuild immediately so the same redraw shows the freshly-revealed | |
| 2325 | - | // rows; otherwise the user would have to scroll again to see them. | |
| 2326 | - | if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) { | |
| 2327 | - | prevLen := len(chat) | |
| 2328 | - | i.view.TailLimit += resumeTailExpandStep | |
| 2329 | - | if i.view.TailLimit >= len(i.view.Messages) { | |
| 2330 | - | i.view.TailLimit = 0 // unbounded | |
| 2331 | - | } | |
| 2332 | - | i.chatCacheValid = false | |
| 2333 | - | chat = i.cachedChatLocked(mainCols) | |
| 2334 | - | for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" { | |
| 2335 | - | chat = chat[:len(chat)-1] | |
| 2336 | - | } | |
| 2337 | - | // Newly-revealed rows are older messages prepended at the top. | |
| 2338 | - | // scrollOffset counts rows from the bottom, so to keep the | |
| 2339 | - | // viewport visually anchored on the same content the user was | |
| 2340 | - | // looking at we shift it up by the number of rows added. Keep | |
| 2341 | - | // the auto-follow baseline (prevChatLen) in sync with the | |
| 2342 | - | // post-expansion length too, so the next render's follow guard | |
| 2343 | - | // doesn't see this growth as a synthetic delta and yank the | |
| 2344 | - | // viewport again. | |
| 2345 | - | if grew := len(chat) - prevLen; grew > 0 { | |
| 2346 | - | i.scrollOffset += grew | |
| 2347 | - | } | |
| 2348 | - | i.prevChatLen = len(chat) | |
| 2349 | - | i.prevChatCols = mainCols | |
| 2350 | - | maxOffset = len(chat) - chatRows | |
| 2351 | - | if maxOffset < 0 { | |
| 2352 | - | maxOffset = 0 | |
| 2353 | - | } | |
| 2354 | - | } | |
| 2355 | - | if i.scrollOffset > maxOffset { | |
| 2356 | - | i.scrollOffset = maxOffset | |
| 2357 | - | } | |
| 2358 | - | if i.scrollOffset < 0 { | |
| 2359 | - | i.scrollOffset = 0 | |
| 2360 | - | } | |
| 2361 | - | ||
| 2362 | - | var visibleChat []string | |
| 2363 | - | if len(chat) <= chatRows { | |
| 2364 | - | visibleChat = chat | |
| 2365 | - | } else { | |
| 2366 | - | end := len(chat) - i.scrollOffset | |
| 2367 | - | rawStart := end - chatRows | |
| 2368 | - | if rawStart < 0 { | |
| 2369 | - | rawStart = 0 | |
| 2370 | - | } | |
| 2371 | - | start := snapViewportStartToImageBlock(chat, rawStart) | |
| 2372 | - | // If the snap pulled start upward (an image-block was atomic) while | |
| 2373 | - | // the user is scrolling downward, the viewport would sit on the same | |
| 2374 | - | // image until the user mashes down past every reserved row. Bump | |
| 2375 | - | // scrollOffset past the image so one keypress always clears it. | |
| 2376 | - | if start < rawStart && i.scrollOffset < i.prevScrollOffset { | |
| 2377 | - | jump := rawStart - start | |
| 2378 | - | i.scrollOffset -= jump | |
| 2379 | - | if i.scrollOffset < 0 { | |
| 2380 | - | i.scrollOffset = 0 | |
| 2381 | - | } | |
| 2382 | - | end = len(chat) - i.scrollOffset | |
| 2383 | - | rawStart = end - chatRows | |
| 2384 | - | if rawStart < 0 { | |
| 2385 | - | rawStart = 0 | |
| 2386 | - | } | |
| 2387 | - | start = snapViewportStartToImageBlock(chat, rawStart) | |
| 2388 | - | } | |
| 2389 | - | end = start + chatRows | |
| 2390 | - | if end > len(chat) { | |
| 2391 | - | end = len(chat) | |
| 2392 | - | start = end - chatRows | |
| 2393 | - | if start < 0 { | |
| 2394 | - | start = 0 | |
| 2395 | - | } | |
| 2396 | - | } | |
| 2397 | - | visibleChat = chat[start:end] | |
| 2398 | - | } | |
| 2399 | - | i.prevScrollOffset = i.scrollOffset | |
| 2400 | - | visibleChat = clipBottomClippedImages(visibleChat) | |
| 2401 | - | ||
| 2402 | - | // A tiny "scrolled up" indicator in the top-right of the chat pane | |
| 2403 | - | // so you know you're not at the bottom. When the viewport was | |
| 2404 | - | // parked by /jump we include the turn number so the user remembers | |
| 2405 | - | // they're reading history rather than the live conversation. | |
| 2406 | - | if i.scrollOffset > 0 && len(visibleChat) > 0 { | |
| 2407 | - | var text string | |
| 2408 | - | if i.parkedTurn > 0 && i.parkedTotal > 0 { | |
| 2409 | - | text = fmt.Sprintf(" ↑ viewing turn %d of %d - %d lines more below (pgdn / end)", | |
| 2410 | - | i.parkedTurn, i.parkedTotal, i.scrollOffset) | |
| 2411 | - | } else { | |
| 2412 | - | text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset) | |
| 2413 | - | } | |
| 2414 | - | note := i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text) | |
| 2415 | - | visibleChat = append([]string{note}, visibleChat...) | |
| 2416 | - | if len(visibleChat) > chatRows { | |
| 2417 | - | visibleChat = visibleChat[:chatRows] | |
| 2418 | - | } | |
| 2419 | - | } | |
| 2420 | - | ||
| 2421 | - | // A dialog is the foreground layer. Dim every other visible surface, | |
| 2422 | - | // including the main input beneath it, without changing the dialog's | |
| 2423 | - | // own styling. Keep the source slices untouched because chat is cached. | |
| 2424 | - | if modalBackdrop { | |
| 2425 | - | chat = tui.DimLines(chat) | |
| 2426 | - | visibleChat = tui.DimLines(visibleChat) | |
| 2427 | - | dimmedBottom := tui.DimLines(bottom) | |
| 2428 | - | copy(dimmedBottom[1:1+len(dialog)], dialog) | |
| 2429 | - | bottom = dimmedBottom | |
| 2430 | - | } | |
| 2431 | - | ||
| 2432 | - | // Default: the real terminal cursor sits on the main editor's | |
| 2433 | - | // input position. In main-screen log mode cursor rows are relative | |
| 2434 | - | // to the fixed bottom band, not the chat transcript. | |
| 2435 | - | // dialogLead is 1 when the bottom region prepends a blank above | |
| 2436 | - | // the dialog block (whenever a dialog is showing) so popup-side | |
| 2437 | - | // cursor positions still land in the right cell. | |
| 2438 | - | dialogLead := 0 | |
| 2439 | - | if len(dialog) > 0 { | |
| 2440 | - | dialogLead = 1 | |
| 2441 | - | } | |
| 2442 | - | cursorRow := inputStartRow + inputCursorOffset + curR | |
| 2443 | - | cursorCol := curC + inputCursorColOffset | |
| 2444 | - | cursorInDialog := false | |
| 2445 | - | if i.btwDialog.Active() { | |
| 2446 | - | if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 { | |
| 2447 | - | cursorRow = dialogLead + r | |
| 2448 | - | cursorCol = c | |
| 2449 | - | cursorInDialog = true | |
| 2450 | - | } | |
| 2451 | - | } | |
| 2452 | - | if i.dialog.Active() { | |
| 2453 | - | if r, c := i.dialog.CursorPos(mainCols); r >= 0 { | |
| 2454 | - | cursorRow = dialogLead + r | |
| 2455 | - | cursorCol = c | |
| 2456 | - | cursorInDialog = true | |
| 2457 | - | } | |
| 2458 | - | } | |
| 2459 | - | if i.llamaDialog.Active() { | |
| 2460 | - | if r, c := i.llamaDialog.CursorPos(); r >= 0 { | |
| 2461 | - | cursorRow = dialogLead + r | |
| 2462 | - | cursorCol = c | |
| 2463 | - | cursorInDialog = true | |
| 2464 | - | } else { | |
| 2465 | - | cursorRow = -1 | |
| 2466 | - | cursorCol = 0 | |
| 2467 | - | } | |
| 2468 | - | } | |
| 2469 | - | if i.sessionDialog.Active() { | |
| 2470 | - | if r, c := i.sessionDialog.CursorPos(); r >= 0 { | |
| 2471 | - | cursorRow = dialogLead + r | |
| 2472 | - | cursorCol = c | |
| 2473 | - | cursorInDialog = true | |
| 2474 | - | } | |
| 2475 | - | } | |
| 2476 | - | if i.sessionTreeDialog.Active() { | |
| 2477 | - | if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 { | |
| 2478 | - | cursorRow = dialogLead + r | |
| 2479 | - | cursorCol = c | |
| 2480 | - | cursorInDialog = true | |
| 2481 | - | } | |
| 2482 | - | } | |
| 2483 | - | if i.extPanel.Active() { | |
| 2484 | - | cursorRow = -1 | |
| 2485 | - | cursorCol = 0 | |
| 2486 | - | } | |
| 2487 | - | i.setInputCursorDimmed(modalBackdrop && !cursorInDialog) | |
| 2488 | - | if i.renderRevision.Load() != renderRevision { | |
| 2489 | - | i.mu.Unlock() | |
| 2490 | - | return | |
| 2491 | - | } | |
| 2492 | - | theme := i.cfg.Theme | |
| 2493 | - | // State assembly above is synchronized; terminal output is owned by | |
| 2494 | - | // this renderer goroutine and must not hold the global state mutex. | |
| 2495 | - | i.mu.Unlock() | |
| 2496 | - | if rightBarActive { | |
| 2497 | - | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | |
| 2498 | - | if modalBackdrop { | |
| 2499 | - | i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol) | |
| 2500 | - | } else { | |
| 2501 | - | i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol) | |
| 2502 | - | } | |
| 2503 | - | } else { | |
| 2504 | - | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | |
| 2505 | - | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |
| 2506 | - | } | |
| 2507 | - | i.mu.Lock() | |
| 2508 | - | if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 { | |
| 2509 | - | alert := *i.pendingAlert | |
| 2510 | - | i.pendingAlert = nil | |
| 2511 | - | i.emitAlertLocked(alert) | |
| 2512 | - | } | |
| 2513 | - | i.mu.Unlock() | |
| 2514 | - | } | |
| 2515 | - | ||
| 2516 | - | func hasImageEscape(line string) bool { | |
| 2517 | - | return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") | |
| 2518 | - | } | |
| 2519 | 1402 | ||
| 2520 | 1403 | // snapViewportStartToImageBlock treats inline images as atomic blocks for | |
| 2521 | 1404 | // scrolling. Terminal image protocols draw from a single escape row into a | |
| @@ -2523,3188 +1406,130 @@ func hasImageEscape(line string) bool { | |||
| 2523 | 1406 | // footprint. If the viewport starts on one of those blank rows, there is no | |
| 2524 | 1407 | // correct partial-image state to render. Snap back to the escape row instead | |
| 2525 | 1408 | // so the image is either shown from its beginning or skipped entirely. | |
| 2526 | - | func snapViewportStartToImageBlock(chat []string, start int) int { | |
| 2527 | - | if start <= 0 || start >= len(chat) { | |
| 2528 | - | return start | |
| 2529 | - | } | |
| 2530 | - | if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) { | |
| 2531 | - | return start | |
| 2532 | - | } | |
| 2533 | - | for k := start - 1; k >= 0; k-- { | |
| 2534 | - | line := chat[k] | |
| 2535 | - | if hasImageEscape(line) { | |
| 2536 | - | return k | |
| 2537 | - | } | |
| 2538 | - | if !isBoxBlankLine(line) { | |
| 2539 | - | break | |
| 2540 | - | } | |
| 2541 | - | } | |
| 2542 | - | return start | |
| 2543 | - | } | |
| 2544 | 1409 | ||
| 2545 | 1410 | const ( | |
| 2546 | - | hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:" | |
| 2547 | - | autoCompactContinueMetaKey = "auto_compact_continue" | |
| 2548 | - | shellEscapeMetaKey = "shell_escape" | |
| 2549 | - | ) | |
| 2550 | - | ||
| 2551 | - | func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message { | |
| 2552 | - | if len(msgs) == 0 { | |
| 2553 | - | return nil | |
| 2554 | - | } | |
| 2555 | - | out := make([]provider.Message, 0, len(msgs)) | |
| 2556 | - | for _, m := range msgs { | |
| 2557 | - | if isHiddenTranscriptMessage(m) { | |
| 2558 | - | continue | |
| 2559 | - | } | |
| 2560 | - | out = append(out, m) | |
| 2561 | - | } | |
| 2562 | - | return out | |
| 2563 | - | } | |
| 2564 | - | ||
| 2565 | - | func isHiddenTranscriptMessage(m provider.Message) bool { | |
| 2566 | - | if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" { | |
| 2567 | - | return true | |
| 2568 | - | } | |
| 2569 | - | if m.Role != provider.RoleUser || len(m.Content) == 0 { | |
| 2570 | - | return false | |
| 2571 | - | } | |
| 2572 | - | tb, ok := m.Content[0].(provider.TextBlock) | |
| 2573 | - | if !ok { | |
| 2574 | - | return false | |
| 2575 | - | } | |
| 2576 | - | return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix | |
| 2577 | - | } | |
| 2578 | - | ||
| 2579 | - | func clipBottomClippedImages(lines []string) []string { | |
| 2580 | - | if len(lines) == 0 { | |
| 2581 | - | return lines | |
| 2582 | - | } | |
| 2583 | - | out := append([]string(nil), lines...) | |
| 2584 | - | for i, line := range out { | |
| 2585 | - | if !hasImageEscape(line) { | |
| 2586 | - | continue | |
| 2587 | - | } | |
| 2588 | - | // Image blocks render as: image escape, zero or more blank | |
| 2589 | - | // reservation rows, then the muted "image - ..." info line, | |
| 2590 | - | // then one trailing blank. If the info line isn't visible in | |
| 2591 | - | // the current chat slice, the image would paint down into the | |
| 2592 | - | // fixed status bar area. Suppress that image for this frame. | |
| 2593 | - | // | |
| 2594 | - | // When the image lives inside a tool box, the reservation rows | |
| 2595 | - | // are wrapped in vertical box edges ("│ ... │"); those rows | |
| 2596 | - | // look non-blank under a naive whitespace check but are still | |
| 2597 | - | // reservation rows for this scan, so treat them as blank. | |
| 2598 | - | foundInfo := false | |
| 2599 | - | for j := i + 1; j < len(out); j++ { | |
| 2600 | - | if strings.Contains(out[j], "image - ") { | |
| 2601 | - | foundInfo = true | |
| 2602 | - | break | |
| 2603 | - | } | |
| 2604 | - | if !isBoxBlankLine(out[j]) { | |
| 2605 | - | break | |
| 2606 | - | } | |
| 2607 | - | } | |
| 2608 | - | if !foundInfo { | |
| 2609 | - | out[i] = "" | |
| 2610 | - | } | |
| 2611 | - | } | |
| 2612 | - | return out | |
| 2613 | - | } | |
| 2614 | - | ||
| 2615 | - | // isBoxBlankLine reports whether line is visually empty after | |
| 2616 | - | // stripping ANSI escape sequences, surrounding whitespace, and the | |
| 2617 | - | // vertical box edges drawn by the tool-box renderer. Used by | |
| 2618 | - | // clipBottomClippedImages so an image's reservation rows still count | |
| 2619 | - | // as blank when those rows are wrapped in "│ ... │" inside a tool box. | |
| 2620 | - | func isBoxBlankLine(line string) bool { | |
| 2621 | - | stripped := stripANSIBytes(line) | |
| 2622 | - | stripped = strings.TrimSpace(stripped) | |
| 2623 | - | stripped = strings.Trim(stripped, "│") | |
| 2624 | - | stripped = strings.TrimSpace(stripped) | |
| 2625 | - | return stripped == "" | |
| 2626 | - | } | |
| 2627 | - | ||
| 2628 | - | // stripANSIBytes removes ANSI CSI escape sequences (ESC '[' ... final | |
| 2629 | - | // byte) from s without pulling in the regexp package. Mirrors the | |
| 2630 | - | // internal helper in package tui; the duplicated copy avoids exporting | |
| 2631 | - | // it just for one caller. | |
| 2632 | - | func stripANSIBytes(s string) string { | |
| 2633 | - | if !strings.Contains(s, "\x1b") { | |
| 2634 | - | return s | |
| 2635 | - | } | |
| 2636 | - | var b strings.Builder | |
| 2637 | - | b.Grow(len(s)) | |
| 2638 | - | i := 0 | |
| 2639 | - | for i < len(s) { | |
| 2640 | - | if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' { | |
| 2641 | - | end := i + 2 | |
| 2642 | - | for end < len(s) { | |
| 2643 | - | c := s[end] | |
| 2644 | - | end++ | |
| 2645 | - | if c >= 0x40 && c <= 0x7e { | |
| 2646 | - | break | |
| 2647 | - | } | |
| 2648 | - | } | |
| 2649 | - | i = end | |
| 2650 | - | continue | |
| 2651 | - | } | |
| 2652 | - | b.WriteByte(s[i]) | |
| 2653 | - | i++ | |
| 2654 | - | } | |
| 2655 | - | return b.String() | |
| 2656 | - | } | |
| 2657 | - | ||
| 2658 | - | // truncateLine shortens s so it fits within n display cells, with an | |
| 2659 | - | // ellipsis if trimmed. Used by the "sliding in" chips so a pasted | |
| 2660 | - | // novel doesn't blow past the status line. | |
| 2661 | - | func panelKeyName(k tui.Key) string { | |
| 2662 | - | switch k.Kind { | |
| 2663 | - | case tui.KeyUp: | |
| 2664 | - | return "up" | |
| 2665 | - | case tui.KeyDown: | |
| 2666 | - | return "down" | |
| 2667 | - | case tui.KeyLeft: | |
| 2668 | - | return "left" | |
| 2669 | - | case tui.KeyRight: | |
| 2670 | - | return "right" | |
| 2671 | - | case tui.KeyEnter: | |
| 2672 | - | return "enter" | |
| 2673 | - | case tui.KeyEsc: | |
| 2674 | - | return "esc" | |
| 2675 | - | case tui.KeyTab: | |
| 2676 | - | return "tab" | |
| 2677 | - | case tui.KeyBackspace: | |
| 2678 | - | return "backspace" | |
| 2679 | - | case tui.KeyDelete: | |
| 2680 | - | return "delete" | |
| 2681 | - | case tui.KeyHome: | |
| 2682 | - | return "home" | |
| 2683 | - | case tui.KeyEnd: | |
| 2684 | - | return "end" | |
| 2685 | - | case tui.KeyPageUp: | |
| 2686 | - | return "pageup" | |
| 2687 | - | case tui.KeyPageDown: | |
| 2688 | - | return "pagedown" | |
| 2689 | - | case tui.KeyRune: | |
| 2690 | - | return "rune" | |
| 2691 | - | default: | |
| 2692 | - | return "unknown" | |
| 2693 | - | } | |
| 2694 | - | } | |
| 2695 | - | ||
| 2696 | - | func panelKeyText(k tui.Key) string { | |
| 2697 | - | if k.Kind == tui.KeyRune { | |
| 2698 | - | return string(k.Rune) | |
| 2699 | - | } | |
| 2700 | - | return "" | |
| 2701 | - | } | |
| 2702 | - | ||
| 2703 | - | func truncateLine(s string, n int) string { | |
| 2704 | - | if n <= 0 { | |
| 2705 | - | return "" | |
| 2706 | - | } | |
| 2707 | - | // Collapse newlines — chips are single line. | |
| 2708 | - | s = strings.ReplaceAll(s, "\n", " ↩ ") | |
| 2709 | - | runes := []rune(s) | |
| 2710 | - | if len(runes) <= n { | |
| 2711 | - | return s | |
| 2712 | - | } | |
| 2713 | - | if n <= 3 { | |
| 2714 | - | return strings.Repeat(".", n) | |
| 2715 | - | } | |
| 2716 | - | return string(runes[:n-3]) + "..." | |
| 2717 | - | } | |
| 2718 | - | ||
| 2719 | - | // ctrlCExitWindow is how long after a ctrl+c press a *second* press | |
| 2720 | - | // will exit instead of just clearing input. Long enough to be | |
| 2721 | - | // deliberate (rules out accidental key chord), short enough that the | |
| 2722 | - | // hint stays meaningful. | |
| 2723 | - | const ctrlCExitWindow = 2 * time.Second | |
| 2724 | - | ||
| 2725 | - | // armCtrlCExit records the timestamp of the current ctrl+c so the next | |
| 2726 | - | // one within ctrlCExitWindow exits. | |
| 2727 | - | func (i *Interactive) armCtrlCExit() { | |
| 2728 | - | i.mu.Lock() | |
| 2729 | - | i.lastCtrlC = time.Now() | |
| 2730 | - | i.mu.Unlock() | |
| 2731 | - | } | |
| 2732 | - | ||
| 2733 | - | // ctrlCExitArmed reports whether a previous ctrl+c was recent enough | |
| 2734 | - | // that another press should now exit. | |
| 2735 | - | func (i *Interactive) ctrlCExitArmed() bool { | |
| 2736 | - | i.mu.Lock() | |
| 2737 | - | t := i.lastCtrlC | |
| 2738 | - | i.mu.Unlock() | |
| 2739 | - | return !t.IsZero() && time.Since(t) <= ctrlCExitWindow | |
| 2740 | - | } | |
| 2741 | - | ||
| 2742 | - | // clearFileSuggestQuery strips the filter the user typed after the | |
| 2743 | - | // last "@", leaving the bare "@" so the picker stays open. Called when | |
| 2744 | - | // navigating between directory levels (Right/Left): the filter applied | |
| 2745 | - | // to the level the user was on, not the one being entered, so carrying | |
| 2746 | - | // it forward would wrongly hide the new directory's contents. | |
| 2747 | - | func (i *Interactive) clearFileSuggestQuery() { | |
| 2748 | - | val := i.ed.Value() | |
| 2749 | - | if idx := strings.LastIndex(val, "@"); idx >= 0 { | |
| 2750 | - | i.ed.SetValue(val[:idx+1]) | |
| 2751 | - | } | |
| 2752 | - | } | |
| 2753 | - | ||
| 2754 | - | // setToolExpansion updates long tool result expansion and replays the | |
| 2755 | - | // transcript so already-emitted rows reflect the new state. | |
| 2756 | - | func (i *Interactive) setToolExpansion(expanded bool) { | |
| 2757 | - | i.mu.Lock() | |
| 2758 | - | i.view.ExpandAll = expanded | |
| 2759 | - | i.requestRendererClear() | |
| 2760 | - | i.mu.Unlock() | |
| 2761 | - | i.invalidate() | |
| 2762 | - | } | |
| 2763 | - | ||
| 2764 | - | func (i *Interactive) toggleToolExpansion() { | |
| 2765 | - | i.mu.Lock() | |
| 2766 | - | expanded := !i.view.ExpandAll | |
| 2767 | - | i.mu.Unlock() | |
| 2768 | - | i.setToolExpansion(expanded) | |
| 2769 | - | } | |
| 2770 | - | ||
| 2771 | - | func (i *Interactive) toggleBtwToolExpansion() { | |
| 2772 | - | i.btwDialog.ToggleToolExpansion() | |
| 2773 | - | i.mu.Lock() | |
| 2774 | - | i.requestRendererClear() | |
| 2775 | - | i.mu.Unlock() | |
| 2776 | - | i.invalidate() | |
| 2777 | - | } | |
| 2778 | - | ||
| 2779 | - | // confirmChildActive reports whether an interaction opened from slash input | |
| 2780 | - | // currently owns the keyboard while a tool confirmation remains pending. | |
| 2781 | - | func (i *Interactive) confirmChildActive() bool { | |
| 2782 | - | i.mu.Lock() | |
| 2783 | - | defer i.mu.Unlock() | |
| 2784 | - | return i.confirmChildActiveLocked() | |
| 2785 | - | } | |
| 2786 | - | ||
| 2787 | - | func (i *Interactive) confirmChildActiveLocked() bool { | |
| 2788 | - | return len(i.helpBlock) > 0 || | |
| 2789 | - | len(i.extNotes) > 0 || | |
| 2790 | - | i.dialog.Active() || | |
| 2791 | - | i.modelDialog.Active() || | |
| 2792 | - | i.llamaDialog.Active() || | |
| 2793 | - | i.rescueDialog.Active() || | |
| 2794 | - | i.sessionDialog.Active() || | |
| 2795 | - | i.residentSubagentsDialog.Active() || | |
| 2796 | - | i.residentChildSession != nil || | |
| 2797 | - | i.jumpDialog.Active() || | |
| 2798 | - | i.btwDialog.Active() || | |
| 2799 | - | i.skillsDialog.Active() || | |
| 2800 | - | i.changelogDialog.Active() || | |
| 2801 | - | i.logoutDialog.Active() || | |
| 2802 | - | i.telegramDialog.Active() || | |
| 2803 | - | i.settingsDialog.Active() || | |
| 2804 | - | i.sessionOpsDialog.Active() || | |
| 2805 | - | i.sessionTreeDialog.Active() || | |
| 2806 | - | i.extPanel.Active() | |
| 2807 | - | } | |
| 2808 | - | ||
| 2809 | - | // restoreConfirmFocus returns input to confirmation after slash input is | |
| 2810 | - | // cleared or a child interaction closes. A non-empty editor keeps command | |
| 2811 | - | // input focused, and an active child continues to own its keys. | |
| 2812 | - | func (i *Interactive) restoreConfirmFocus() { | |
| 2813 | - | if !i.confirmDialog.Active() || i.confirmDialog.Focused() { | |
| 2814 | - | return | |
| 2815 | - | } | |
| 2816 | - | if !i.ed.IsEmpty() || i.confirmChildActive() { | |
| 2817 | - | return | |
| 2818 | - | } | |
| 2819 | - | i.confirmDialog.Focus() | |
| 2820 | - | i.invalidate() | |
| 2821 | - | } | |
| 2822 | - | ||
| 2823 | - | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |
| 2824 | - | defer i.restoreConfirmFocus() | |
| 2825 | - | ||
| 2826 | - | // A bare Escape is eligible for the session-tree gesture only while the | |
| 2827 | - | // main input owns the key. Any other key, modified Escape, or visible | |
| 2828 | - | // child interaction resets the pending first tap before normal routing. | |
| 2829 | - | if !isUnmodifiedEscape(k) || i.sessionTreeEscapeBlocked() { | |
| 2830 | - | i.mu.Lock() | |
| 2831 | - | i.doubleEscape.Reset() | |
| 2832 | - | i.mu.Unlock() | |
| 2833 | - | } | |
| 2834 | - | ||
| 2835 | - | // Dialogs route keys before the main clipboard handler below. Resolve text | |
| 2836 | - | // here when a child interaction owns input so every editor and filter sees | |
| 2837 | - | // the same KeyPaste event as terminal-native bracketed paste. Main chat is | |
| 2838 | - | // left image-first to preserve macOS clipboard image attachments. | |
| 2839 | - | if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() { | |
| 2840 | - | resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText) | |
| 2841 | - | if err != nil { | |
| 2842 | - | i.mu.Lock() | |
| 2843 | - | i.statusErr = "clipboard paste failed: " + err.Error() | |
| 2844 | - | i.statusOK = "" | |
| 2845 | - | i.mu.Unlock() | |
| 2846 | - | return false | |
| 2847 | - | } | |
| 2848 | - | if pastedText { | |
| 2849 | - | k = resolved | |
| 2850 | - | i.mu.Lock() | |
| 2851 | - | i.statusErr = "" | |
| 2852 | - | i.statusOK = "" | |
| 2853 | - | i.mu.Unlock() | |
| 2854 | - | } | |
| 2855 | - | } | |
| 2856 | - | ||
| 2857 | - | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so | |
| 2858 | - | // pressing ctrl+c then typing then ctrl+c much later doesn't quit | |
| 2859 | - | // unexpectedly. The hint message also goes stale; clear it. | |
| 2860 | - | if k.Kind != tui.KeyCtrlC { | |
| 2861 | - | i.mu.Lock() | |
| 2862 | - | if !i.lastCtrlC.IsZero() { | |
| 2863 | - | i.lastCtrlC = time.Time{} | |
| 2864 | - | if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") { | |
| 2865 | - | i.statusOK = "" | |
| 2866 | - | } | |
| 2867 | - | } | |
| 2868 | - | i.mu.Unlock() | |
| 2869 | - | } | |
| 2870 | - | ||
| 2871 | - | // Dialogs consume keys while open (except ctrl+c, which always closes them). | |
| 2872 | - | ||
| 2873 | - | // Confirmation owns input by default. Pressing / deliberately moves | |
| 2874 | - | // focus to the main editor without answering the pending request, so | |
| 2875 | - | // slash commands and the dialogs they open can run first. | |
| 2876 | - | if i.confirmDialog.Focused() { | |
| 2877 | - | if k.Kind == tui.KeyRune && k.Rune == '/' { | |
| 2878 | - | i.confirmDialog.Blur() | |
| 2879 | - | i.ed.Insert("/") | |
| 2880 | - | i.suggest.Reset() | |
| 2881 | - | i.invalidate() | |
| 2882 | - | return false | |
| 2883 | - | } | |
| 2884 | - | if k.Kind == tui.KeyCtrlO { | |
| 2885 | - | if i.btwDialog != nil && i.btwDialog.Active() { | |
| 2886 | - | i.toggleBtwToolExpansion() | |
| 2887 | - | } else { | |
| 2888 | - | i.toggleToolExpansion() | |
| 2889 | - | } | |
| 2890 | - | return false | |
| 2891 | - | } | |
| 2892 | - | i.confirmDialog.HandleKey(k) | |
| 2893 | - | i.invalidate() | |
| 2894 | - | return false | |
| 2895 | - | } | |
| 2896 | - | if i.confirmDialog.Active() && !i.confirmChildActive() && k.Kind == tui.KeyEsc { | |
| 2897 | - | i.ed.Clear() | |
| 2898 | - | i.suggest.Reset() | |
| 2899 | - | i.fileSuggest.Reset() | |
| 2900 | - | i.confirmDialog.Focus() | |
| 2901 | - | i.invalidate() | |
| 2902 | - | return false | |
| 2903 | - | } | |
| 2904 | - | if i.dialog.Active() { | |
| 2905 | - | if k.Kind == tui.KeyCtrlC { | |
| 2906 | - | i.dialog.Close() | |
| 2907 | - | if i.cfg.AuthManager != nil { | |
| 2908 | - | i.cfg.AuthManager.CancelOAuth() | |
| 2909 | - | } | |
| 2910 | - | return false | |
| 2911 | - | } | |
| 2912 | - | act := i.dialog.HandleKey(k) | |
| 2913 | - | if act.StartAPIKey { | |
| 2914 | - | i.startAPIKeyFlow(act.Provider) | |
| 2915 | - | } | |
| 2916 | - | if act.StartOAuth { | |
| 2917 | - | i.startOAuthFlow(act.Provider) | |
| 2918 | - | } | |
| 2919 | - | if act.StartManual { | |
| 2920 | - | i.startManualOAuthFlow(act.Provider) | |
| 2921 | - | } | |
| 2922 | - | if act.SubmitCode != "" { | |
| 2923 | - | i.submitManualOAuthCode(act.SubmitCode) | |
| 2924 | - | } | |
| 2925 | - | if act.SaveLlama { | |
| 2926 | - | i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey) | |
| 2927 | - | } | |
| 2928 | - | return false | |
| 2929 | - | } | |
| 2930 | - | if i.modelDialog.Active() { | |
| 2931 | - | if k.Kind == tui.KeyCtrlC { | |
| 2932 | - | i.modelDialog.Close() | |
| 2933 | - | i.quickModelAssign = 0 | |
| 2934 | - | return false | |
| 2935 | - | } | |
| 2936 | - | act := i.modelDialog.HandleKey(k) | |
| 2937 | - | if act.Close { | |
| 2938 | - | i.quickModelAssign = 0 | |
| 2939 | - | } | |
| 2940 | - | if act.ReasoningChanged { | |
| 2941 | - | i.applyReasoningSetting(act.Reasoning) | |
| 2942 | - | } | |
| 2943 | - | if act.Select { | |
| 2944 | - | if i.quickModelAssign > 0 { | |
| 2945 | - | i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model) | |
| 2946 | - | i.quickModelAssign = 0 | |
| 2947 | - | } else { | |
| 2948 | - | i.applyModelSelection(act.Provider, act.Model) | |
| 2949 | - | } | |
| 2950 | - | } | |
| 2951 | - | return false | |
| 2952 | - | } | |
| 2953 | - | if i.llamaDialog.Active() { | |
| 2954 | - | i.llamaDialog.HandleKey(k) | |
| 2955 | - | i.invalidate() | |
| 2956 | - | return false | |
| 2957 | - | } | |
| 2958 | - | if i.rescueDialog.Active() { | |
| 2959 | - | if k.Kind == tui.KeyCtrlC { | |
| 2960 | - | i.rescueDialog.Close() | |
| 2961 | - | i.invalidate() | |
| 2962 | - | return false | |
| 2963 | - | } | |
| 2964 | - | act := i.rescueDialog.HandleKey(k) | |
| 2965 | - | if act.Select { | |
| 2966 | - | i.applyRescueSelection(act.Provider, act.Model, act.Prompt) | |
| 2967 | - | } | |
| 2968 | - | i.invalidate() | |
| 2969 | - | return false | |
| 2970 | - | } | |
| 2971 | - | i.mu.Lock() | |
| 2972 | - | sessionDialogActive := i.sessionDialog.Active() | |
| 2973 | - | i.mu.Unlock() | |
| 2974 | - | if sessionDialogActive { | |
| 2975 | - | if k.Kind == tui.KeyCtrlC { | |
| 2976 | - | i.mu.Lock() | |
| 2977 | - | i.sessionDialog.Close() | |
| 2978 | - | i.sessionLoads = nil | |
| 2979 | - | i.mu.Unlock() | |
| 2980 | - | i.invalidate() | |
| 2981 | - | return false | |
| 2982 | - | } | |
| 2983 | - | manualRenamePath := "" | |
| 2984 | - | i.mu.Lock() | |
| 2985 | - | if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil { | |
| 2986 | - | manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path | |
| 2987 | - | } | |
| 2988 | - | i.mu.Unlock() | |
| 2989 | - | manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath | |
| 2990 | - | if manualRenameCurrent { | |
| 2991 | - | i.markSessionTitleSwitching() | |
| 2992 | - | } | |
| 2993 | - | i.mu.Lock() | |
| 2994 | - | act := i.sessionDialog.HandleKey(k) | |
| 2995 | - | if act.Select || act.Close { | |
| 2996 | - | i.sessionLoads = nil | |
| 2997 | - | } | |
| 2998 | - | i.mu.Unlock() | |
| 2999 | - | if act.Select { | |
| 3000 | - | i.applySessionSelection(act.Path) | |
| 3001 | - | } | |
| 3002 | - | if act.Err != nil { | |
| 3003 | - | if manualRenameCurrent { | |
| 3004 | - | i.restoreFailedSessionTitle() | |
| 3005 | - | } | |
| 3006 | - | i.mu.Lock() | |
| 3007 | - | i.statusErr = "rename: " + act.Err.Error() | |
| 3008 | - | i.statusOK = "" | |
| 3009 | - | i.mu.Unlock() | |
| 3010 | - | } else if act.Renamed && act.Path != "" && i.cfg.CurrentSessionPath != nil && i.cfg.CurrentSessionPath() == act.Path { | |
| 3011 | - | i.setManualSessionTitle(act.RenameTitle) | |
| 3012 | - | } | |
| 3013 | - | // Always request a redraw after handling a key here: when esc | |
| 3014 | - | // closes the picker, the overlay-close detection in the render | |
| 3015 | - | // pass needs to run so the tall dialog rows get repainted from | |
| 3016 | - | // the chat (otherwise VS Code's retained scrollback leaves a | |
| 3017 | - | // duplicate frame on screen). | |
| 3018 | - | i.invalidate() | |
| 3019 | - | return false | |
| 3020 | - | } | |
| 3021 | - | i.mu.Lock() | |
| 3022 | - | residentDialog := i.residentSubagentsDialog | |
| 3023 | - | residentDialogActive := residentDialog != nil && residentDialog.Active() | |
| 3024 | - | residentSession := i.residentChildSession | |
| 3025 | - | i.mu.Unlock() | |
| 3026 | - | if residentDialogActive { | |
| 3027 | - | if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc { | |
| 3028 | - | i.mu.Lock() | |
| 3029 | - | if i.residentSubagentsDialog == residentDialog { | |
| 3030 | - | residentDialog.Close() | |
| 3031 | - | } | |
| 3032 | - | i.mu.Unlock() | |
| 3033 | - | i.invalidate() | |
| 3034 | - | return false | |
| 3035 | - | } | |
| 3036 | - | i.mu.Lock() | |
| 3037 | - | childID := residentDialog.HandleKey(k) | |
| 3038 | - | i.mu.Unlock() | |
| 3039 | - | if childID != "" { | |
| 3040 | - | i.openResidentChildSession(childID) | |
| 3041 | - | } | |
| 3042 | - | i.invalidate() | |
| 3043 | - | return false | |
| 3044 | - | } | |
| 3045 | - | if residentSession != nil { | |
| 3046 | - | switch k.Kind { | |
| 3047 | - | case tui.KeyCtrlC, tui.KeyEsc: | |
| 3048 | - | i.mu.Lock() | |
| 3049 | - | if i.residentChildSession == residentSession { | |
| 3050 | - | i.residentChildSession = nil | |
| 3051 | - | } | |
| 3052 | - | i.mu.Unlock() | |
| 3053 | - | case tui.KeyPageUp: | |
| 3054 | - | session := residentSession | |
| 3055 | - | if session.BeginLoad() { | |
| 3056 | - | go func() { | |
| 3057 | - | err := session.LoadOlder(200) | |
| 3058 | - | if session.FinishLoad(err) { | |
| 3059 | - | i.reloadResidentChildSession(session) | |
| 3060 | - | } | |
| 3061 | - | i.invalidate() | |
| 3062 | - | }() | |
| 3063 | - | } | |
| 3064 | - | case tui.KeyPageDown: | |
| 3065 | - | residentSession.FollowTail() | |
| 3066 | - | case tui.KeyUp: | |
| 3067 | - | residentSession.Scroll(1) | |
| 3068 | - | case tui.KeyDown: | |
| 3069 | - | residentSession.Scroll(-1) | |
| 3070 | - | default: | |
| 3071 | - | session := residentSession | |
| 3072 | - | if prompt, submit := session.HandleKey(k); submit { | |
| 3073 | - | go func() { | |
| 3074 | - | err := i.cfg.ResidentManager.Resume(ctx, session.childID, prompt) | |
| 3075 | - | session.FinishSubmission(err) | |
| 3076 | - | i.invalidate() | |
| 3077 | - | }() | |
| 3078 | - | } | |
| 3079 | - | } | |
| 3080 | - | i.invalidate() | |
| 3081 | - | return false | |
| 3082 | - | } | |
| 3083 | - | if i.logoutDialog.Active() { | |
| 3084 | - | if k.Kind == tui.KeyCtrlC { | |
| 3085 | - | i.logoutDialog.Close() | |
| 3086 | - | i.invalidate() | |
| 3087 | - | return false | |
| 3088 | - | } | |
| 3089 | - | act := i.logoutDialog.HandleKey(k) | |
| 3090 | - | if act.Select { | |
| 3091 | - | i.doLogout(act.Target) | |
| 3092 | - | } | |
| 3093 | - | i.invalidate() | |
| 3094 | - | return false | |
| 3095 | - | } | |
| 3096 | - | if i.telegramDialog.Active() { | |
| 3097 | - | if k.Kind == tui.KeyCtrlC { | |
| 3098 | - | i.telegramDialog.Close() | |
| 3099 | - | i.invalidate() | |
| 3100 | - | return false | |
| 3101 | - | } | |
| 3102 | - | act := i.telegramDialog.HandleKey(k) | |
| 3103 | - | if act.Select { | |
| 3104 | - | i.doTelegram(act.Action) | |
| 3105 | - | } | |
| 3106 | - | i.invalidate() | |
| 3107 | - | return false | |
| 3108 | - | } | |
| 3109 | - | if i.settingsDialog.Active() { | |
| 3110 | - | if k.Kind == tui.KeyCtrlC { | |
| 3111 | - | i.settingsDialog.Close() | |
| 3112 | - | i.invalidate() | |
| 3113 | - | return false | |
| 3114 | - | } | |
| 3115 | - | act := i.settingsDialog.HandleKey(k) | |
| 3116 | - | if act.ModelShortcutSlot > 0 { | |
| 3117 | - | i.openQuickModelPicker(act.ModelShortcutSlot) | |
| 3118 | - | } | |
| 3119 | - | if act.Toggle { | |
| 3120 | - | i.applySettingChange(act) | |
| 3121 | - | } | |
| 3122 | - | i.invalidate() | |
| 3123 | - | return false | |
| 3124 | - | } | |
| 3125 | - | if i.sessionOpsDialog.Active() { | |
| 3126 | - | if k.Kind == tui.KeyCtrlC { | |
| 3127 | - | i.sessionOpsDialog.Close() | |
| 3128 | - | i.invalidate() | |
| 3129 | - | return false | |
| 3130 | - | } | |
| 3131 | - | act := i.sessionOpsDialog.HandleKey(k) | |
| 3132 | - | if act.Select { | |
| 3133 | - | i.doSessionOp(act.Action, "") | |
| 3134 | - | } | |
| 3135 | - | i.invalidate() | |
| 3136 | - | return false | |
| 3137 | - | } | |
| 3138 | - | if i.sessionTreeDialog.Active() { | |
| 3139 | - | if k.Kind == tui.KeyCtrlC { | |
| 3140 | - | i.sessionTreeDialog.Close() | |
| 3141 | - | i.invalidate() | |
| 3142 | - | return false | |
| 3143 | - | } | |
| 3144 | - | act := i.sessionTreeDialog.HandleKey(k) | |
| 3145 | - | if act.Select { | |
| 3146 | - | if act.Target.Role != "" || act.Target.SourcePath != "" || act.Target.IsBoundary() || act.Target.UserDraft != "" { | |
| 3147 | - | i.applySessionTreeTarget(act.Target, act.TurnNo) | |
| 3148 | - | } else { | |
| 3149 | - | // Keep package embedders that still return the scalar action | |
| 3150 | - | // source-compatible while the dialog migrates to Target. | |
| 3151 | - | i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt) | |
| 3152 | - | } | |
| 3153 | - | } | |
| 3154 | - | i.invalidate() | |
| 3155 | - | return false | |
| 3156 | - | } | |
| 3157 | - | if i.extPanel.Active() { | |
| 3158 | - | if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc { | |
| 3159 | - | if i.cfg.Extensions != nil { | |
| 3160 | - | _ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id) | |
| 3161 | - | } | |
| 3162 | - | i.extPanel.Close() | |
| 3163 | - | i.invalidate() | |
| 3164 | - | return false | |
| 3165 | - | } | |
| 3166 | - | if i.cfg.Extensions != nil { | |
| 3167 | - | _ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k)) | |
| 3168 | - | } | |
| 3169 | - | return false | |
| 3170 | - | } | |
| 3171 | - | if i.jumpDialog.Active() { | |
| 3172 | - | if k.Kind == tui.KeyCtrlC { | |
| 3173 | - | i.jumpDialog.Close() | |
| 3174 | - | i.pendingFork = false | |
| 3175 | - | return false | |
| 3176 | - | } | |
| 3177 | - | act := i.jumpDialog.HandleKey(k) | |
| 3178 | - | if act.Select { | |
| 3179 | - | if i.pendingFork { | |
| 3180 | - | i.applyForkSelection(act.MessageIdx) | |
| 3181 | - | } else { | |
| 3182 | - | i.applyJumpSelection(act.MessageIdx, act.TurnNo) | |
| 3183 | - | } | |
| 3184 | - | } | |
| 3185 | - | // If the user dismissed the dialog without selecting, also | |
| 3186 | - | // clear the pending-fork flag so a later plain /jump isn't | |
| 3187 | - | // hijacked. | |
| 3188 | - | if act.Close { | |
| 3189 | - | i.pendingFork = false | |
| 3190 | - | } | |
| 3191 | - | return false | |
| 3192 | - | } | |
| 3193 | - | if i.btwDialog.Active() { | |
| 3194 | - | if k.Kind == tui.KeyCtrlC { | |
| 3195 | - | i.btwDialog.Close() | |
| 3196 | - | i.invalidate() | |
| 3197 | - | return false | |
| 3198 | - | } | |
| 3199 | - | if k.Kind == tui.KeyCtrlO { | |
| 3200 | - | i.toggleBtwToolExpansion() | |
| 3201 | - | return false | |
| 3202 | - | } | |
| 3203 | - | i.btwDialog.HandleKey(k, i.invalidate) | |
| 3204 | - | return false | |
| 3205 | - | } | |
| 3206 | - | if i.skillsDialog.Active() { | |
| 3207 | - | if k.Kind == tui.KeyCtrlC { | |
| 3208 | - | i.skillsDialog.Close() | |
| 3209 | - | i.invalidate() | |
| 3210 | - | return false | |
| 3211 | - | } | |
| 3212 | - | i.skillsDialog.HandleKey(k) | |
| 3213 | - | i.invalidate() | |
| 3214 | - | return false | |
| 3215 | - | } | |
| 3216 | - | if i.changelogDialog.Active() { | |
| 3217 | - | if closed := i.changelogDialog.HandleKey(k); closed { | |
| 3218 | - | // User dismissed; let the parent persist the | |
| 3219 | - | // LastChangelogShown marker via the close callback. | |
| 3220 | - | if i.cfg.OnChangelogDismiss != nil { | |
| 3221 | - | i.cfg.OnChangelogDismiss() | |
| 3222 | - | } | |
| 3223 | - | } | |
| 3224 | - | i.invalidate() | |
| 3225 | - | return false | |
| 3226 | - | } | |
| 3227 | - | ||
| 3228 | - | if slot := quickModelShortcutSlot(k); slot > 0 { | |
| 3229 | - | i.applyQuickModelShortcut(slot) | |
| 3230 | - | return false | |
| 3231 | - | } | |
| 3232 | - | ||
| 3233 | - | // The first eligible bare Escape is deliberately left to the existing | |
| 3234 | - | // Escape path (which clears idle input or cancels a turn). A second tap | |
| 3235 | - | // within the gesture window only gets first refusal while the cheap, | |
| 3236 | - | // local eligibility rules still hold. If a turn started, a message was | |
| 3237 | - | // queued, or the input became ineligible between taps, reset the detector | |
| 3238 | - | // and fall through so Escape retains its normal cancellation behavior. | |
| 3239 | - | // Once those rules still hold, the full tree gate owns the second tap: | |
| 3240 | - | // family/read failures are fail-closed but are nevertheless consumed so | |
| 3241 | - | // they cannot fall through into ordinary editor handling. | |
| 3242 | - | if isUnmodifiedEscape(k) { | |
| 3243 | - | now := i.sessionTreeEscapeNow() | |
| 3244 | - | i.mu.Lock() | |
| 3245 | - | consumed := i.doubleEscape.Consume(now) | |
| 3246 | - | i.mu.Unlock() | |
| 3247 | - | if consumed { | |
| 3248 | - | if i.canArmSessionTreeEscape() { | |
| 3249 | - | i.openSessionTree() | |
| 3250 | - | return false | |
| 3251 | - | } | |
| 3252 | - | i.mu.Lock() | |
| 3253 | - | i.doubleEscape.Reset() | |
| 3254 | - | i.mu.Unlock() | |
| 3255 | - | } else if i.canArmSessionTreeEscape() { | |
| 3256 | - | i.mu.Lock() | |
| 3257 | - | i.doubleEscape.Arm(now) | |
| 3258 | - | i.mu.Unlock() | |
| 3259 | - | } | |
| 3260 | - | } | |
| 3261 | - | ||
| 3262 | - | // Global keys. | |
| 3263 | - | switch k.Kind { | |
| 3264 | - | case tui.KeyCtrlC: | |
| 3265 | - | i.mu.Lock() | |
| 3266 | - | loadingSession := i.sessionLoading | |
| 3267 | - | i.mu.Unlock() | |
| 3268 | - | if loadingSession { | |
| 3269 | - | i.markCtrlCExit() | |
| 3270 | - | return true | |
| 3271 | - | } | |
| 3272 | - | // While busy: do NOT cancel the turn. ctrl+c during a | |
| 3273 | - | // running turn is almost always reflex muscle memory | |
| 3274 | - | // ("be quiet" in a shell) rather than a deliberate | |
| 3275 | - | // decision to kill a multi-minute model call that's | |
| 3276 | - | // already cost tokens. Use esc to interrupt a turn; use | |
| 3277 | - | // a deliberate double-ctrl+c to exit zut entirely. First | |
| 3278 | - | // press arms the exit hint, second press within | |
| 3279 | - | // ctrlCExitWindow quits. | |
| 3280 | - | if i.busy { | |
| 3281 | - | if i.ctrlCExitArmed() { | |
| 3282 | - | i.markCtrlCExit() | |
| 3283 | - | return true | |
| 3284 | - | } | |
| 3285 | - | i.mu.Lock() | |
| 3286 | - | i.statusOK = "press ctrl+c again to exit, esc to cancel the turn" | |
| 3287 | - | i.statusErr = "" | |
| 3288 | - | i.mu.Unlock() | |
| 3289 | - | i.armCtrlCExit() | |
| 3290 | - | return false | |
| 3291 | - | } | |
| 3292 | - | // Idle: first press clears the editor (and any queued | |
| 3293 | - | // follow-up messages); a second press within ctrlCExitWindow | |
| 3294 | - | // exits. With both an empty editor and no queue the first | |
| 3295 | - | // press still just arms — require a deliberate double-tap. | |
| 3296 | - | ag := i.agent | |
| 3297 | - | pending := 0 | |
| 3298 | - | if ag != nil { | |
| 3299 | - | pending = ag.QueuedMessageCount() | |
| 3300 | - | } | |
| 3301 | - | hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0 | |
| 3302 | - | if hadInput { | |
| 3303 | - | i.ed.Clear() | |
| 3304 | - | i.clipboardImages = nil | |
| 3305 | - | i.suggest.Reset() | |
| 3306 | - | if ag != nil { | |
| 3307 | - | ag.DrainQueuedMessages() | |
| 3308 | - | } | |
| 3309 | - | i.mu.Lock() | |
| 3310 | - | i.queued = nil | |
| 3311 | - | i.statusOK = "input cleared" | |
| 3312 | - | i.statusErr = "" | |
| 3313 | - | i.mu.Unlock() | |
| 3314 | - | i.armCtrlCExit() | |
| 3315 | - | return false | |
| 3316 | - | } | |
| 3317 | - | if i.ctrlCExitArmed() { | |
| 3318 | - | i.markCtrlCExit() | |
| 3319 | - | return true | |
| 3320 | - | } | |
| 3321 | - | i.mu.Lock() | |
| 3322 | - | i.statusOK = "press ctrl+c again to exit" | |
| 3323 | - | i.statusErr = "" | |
| 3324 | - | i.mu.Unlock() | |
| 3325 | - | i.armCtrlCExit() | |
| 3326 | - | return false | |
| 3327 | - | case tui.KeyEsc: | |
| 3328 | - | // Esc interrupts a running turn — but only when nothing | |
| 3329 | - | // else on screen wants to consume the key first. The slash | |
| 3330 | - | // popup has its own Esc behaviour (close + clear editor), | |
| 3331 | - | // and transient overlays like the /help block and extension | |
| 3332 | - | // notes should dismiss on Esc before we even consider the | |
| 3333 | - | // turn. Without these guards, a casual Esc press after | |
| 3334 | - | // running /help on a busy turn rips the turn away. | |
| 3335 | - | if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) { | |
| 3336 | - | break | |
| 3337 | - | } | |
| 3338 | - | i.mu.Lock() | |
| 3339 | - | hadHelp := len(i.helpBlock) > 0 | |
| 3340 | - | hadNotes := len(i.extNotes) > 0 | |
| 3341 | - | if hadHelp { | |
| 3342 | - | i.helpBlock = nil | |
| 3343 | - | } | |
| 3344 | - | if hadNotes { | |
| 3345 | - | i.extNotes = nil | |
| 3346 | - | } | |
| 3347 | - | i.mu.Unlock() | |
| 3348 | - | if hadHelp || hadNotes { | |
| 3349 | - | i.invalidate() | |
| 3350 | - | return false | |
| 3351 | - | } | |
| 3352 | - | i.mu.Lock() | |
| 3353 | - | busyCancel := i.busy && i.cancelTurn != nil | |
| 3354 | - | cancelTurn := i.cancelTurn | |
| 3355 | - | var restoreQueued string | |
| 3356 | - | var handoff json.RawMessage | |
| 3357 | - | var persistHandoff bool | |
| 3358 | - | if busyCancel { | |
| 3359 | - | // Keep the most recently queued follow-up as an editable draft | |
| 3360 | - | // instead of losing it with the cancelled turn's stale queue. | |
| 3361 | - | if i.agent != nil { | |
| 3362 | - | restoreQueued, _ = i.agent.PopQueuedMessage() | |
| 3363 | - | } | |
| 3364 | - | if restoreQueued == "" && len(i.queued) > 0 { | |
| 3365 | - | n := len(i.queued) - 1 | |
| 3366 | - | restoreQueued = i.queued[n] | |
| 3367 | - | i.queued = i.queued[:n] | |
| 3368 | - | } | |
| 3369 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 3370 | - | } | |
| 3371 | - | i.mu.Unlock() | |
| 3372 | - | if busyCancel { | |
| 3373 | - | if restoreQueued != "" { | |
| 3374 | - | i.ed.SetValue(restoreQueued) | |
| 3375 | - | i.inputHistoryIndex = -1 | |
| 3376 | - | } | |
| 3377 | - | i.updateActiveGoal(core.GoalPaused, "interrupted by user") | |
| 3378 | - | cancelTurn() | |
| 3379 | - | // If a confirm dialog is pending, refuse it so the agent | |
| 3380 | - | // goroutine unblocks and the context cancellation can | |
| 3381 | - | // actually take effect. | |
| 3382 | - | i.confirmDialog.CancelAll("turn cancelled") | |
| 3383 | - | if persistHandoff { | |
| 3384 | - | i.persistCompactHandoff(handoff) | |
| 3385 | - | } | |
| 3386 | - | return false | |
| 3387 | - | } | |
| 3388 | - | case tui.KeyCtrlD: | |
| 3389 | - | if i.ed.IsEmpty() && !i.busy { | |
| 3390 | - | return true | |
| 3391 | - | } | |
| 3392 | - | case tui.KeyCtrlB: | |
| 3393 | - | i.mu.Lock() | |
| 3394 | - | i.rightBarHidden = !i.rightBarHidden | |
| 3395 | - | i.mu.Unlock() | |
| 3396 | - | i.requestRendererInvalidate() | |
| 3397 | - | i.invalidate() | |
| 3398 | - | return false | |
| 3399 | - | case tui.KeyCtrlL: | |
| 3400 | - | i.requestRendererClear() | |
| 3401 | - | i.invalidate() | |
| 3402 | - | return false | |
| 3403 | - | case tui.KeyPasteClipboard: | |
| 3404 | - | i.pasteClipboard(ctx) | |
| 3405 | - | return false | |
| 3406 | - | case tui.KeyCtrlO: | |
| 3407 | - | i.toggleToolExpansion() | |
| 3408 | - | return false | |
| 3409 | - | case tui.KeyPageUp: | |
| 3410 | - | i.scrollBy(+i.chatPage()) | |
| 3411 | - | return false | |
| 3412 | - | case tui.KeyPageDown: | |
| 3413 | - | i.scrollBy(-i.chatPage()) | |
| 3414 | - | return false | |
| 3415 | - | case tui.KeyUp: | |
| 3416 | - | // Alt/Option+Up: pop the most recently queued ("sliding in") | |
| 3417 | - | // message back into the editor so the user can edit and | |
| 3418 | - | // resend it. Repeated presses keep peeling messages off the | |
| 3419 | - | // tail of the queue; each press *replaces* the editor | |
| 3420 | - | // contents (we don't append/push). When the queue is empty | |
| 3421 | - | // the keypress falls through to the normal scroll behavior. | |
| 3422 | - | if k.Alt { | |
| 3423 | - | i.mu.Lock() | |
| 3424 | - | var text string | |
| 3425 | - | if i.agent != nil { | |
| 3426 | - | text, _ = i.agent.PopQueuedMessage() | |
| 3427 | - | } | |
| 3428 | - | if text == "" { | |
| 3429 | - | if n := len(i.queued); n > 0 { | |
| 3430 | - | text = i.queued[n-1] | |
| 3431 | - | i.queued = i.queued[:n-1] | |
| 3432 | - | } | |
| 3433 | - | } | |
| 3434 | - | i.mu.Unlock() | |
| 3435 | - | if text != "" { | |
| 3436 | - | i.ed.SetValue(text) | |
| 3437 | - | i.inputHistoryIndex = -1 | |
| 3438 | - | i.invalidate() | |
| 3439 | - | return false | |
| 3440 | - | } | |
| 3441 | - | } | |
| 3442 | - | // In multi-line / wrapped input, Up first moves inside the editor. | |
| 3443 | - | // At the editor's top edge, plain Up can browse input history when | |
| 3444 | - | // history browsing is safe/active; otherwise it falls back to chat | |
| 3445 | - | // scrolling, preserving the old single-line scroll behavior. | |
| 3446 | - | if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | |
| 3447 | - | if i.ed.MoveVertical(-1) { | |
| 3448 | - | i.invalidate() | |
| 3449 | - | return false | |
| 3450 | - | } | |
| 3451 | - | if !k.Alt && i.handleInputHistoryKey(k) { | |
| 3452 | - | return false | |
| 3453 | - | } | |
| 3454 | - | i.scrollBy(+3) | |
| 3455 | - | return false | |
| 3456 | - | } | |
| 3457 | - | case tui.KeyDown: | |
| 3458 | - | if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | |
| 3459 | - | if i.ed.MoveVertical(+1) { | |
| 3460 | - | i.invalidate() | |
| 3461 | - | return false | |
| 3462 | - | } | |
| 3463 | - | if !k.Alt && i.inputHistoryIndex < 0 && i.cfg.ResidentManager != nil { | |
| 3464 | - | if _, total := i.cfg.ResidentManager.RecentSnapshotPage(0, 1); total > 0 { | |
| 3465 | - | i.runResidentSubagents(ctx, nil) | |
| 3466 | - | return false | |
| 3467 | - | } | |
| 3468 | - | } | |
| 3469 | - | if !k.Alt && i.handleInputHistoryKey(k) { | |
| 3470 | - | return false | |
| 3471 | - | } | |
| 3472 | - | if i.scrollOffset > 0 { | |
| 3473 | - | i.scrollBy(-3) | |
| 3474 | - | } | |
| 3475 | - | return false | |
| 3476 | - | } | |
| 3477 | - | } | |
| 3478 | - | ||
| 3479 | - | // Note: we intentionally do NOT gate the editor on i.busy here. | |
| 3480 | - | // Typing while the agent is working is supported — submitted | |
| 3481 | - | // messages are queued and delivered as follow-up turns when the | |
| 3482 | - | // current turn ends. See the submit handler below. | |
| 3483 | - | ||
| 3484 | - | if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) { | |
| 3485 | - | i.ed.HandleKey(k) | |
| 3486 | - | return false | |
| 3487 | - | } | |
| 3488 | - | ||
| 3489 | - | // Slash suggestions: intercept up/down/tab/enter when the popup is visible. | |
| 3490 | - | if i.suggest.Active(i.ed.Value()) { | |
| 3491 | - | switch k.Kind { | |
| 3492 | - | case tui.KeyUp: | |
| 3493 | - | i.suggest.Up() | |
| 3494 | - | return false | |
| 3495 | - | case tui.KeyDown: | |
| 3496 | - | i.suggest.Down() | |
| 3497 | - | return false | |
| 3498 | - | case tui.KeyPageUp: | |
| 3499 | - | i.suggest.PageUp() | |
| 3500 | - | return false | |
| 3501 | - | case tui.KeyPageDown: | |
| 3502 | - | i.suggest.PageDown() | |
| 3503 | - | return false | |
| 3504 | - | case tui.KeyTab: | |
| 3505 | - | if name := i.suggest.Selection(i.ed.Value()); name != "" { | |
| 3506 | - | i.ed.SetValue(name) | |
| 3507 | - | i.suggest.Reset() | |
| 3508 | - | } | |
| 3509 | - | return false | |
| 3510 | - | case tui.KeyEnter: | |
| 3511 | - | // Enter on an ambiguous or partial slash prefix: complete to the | |
| 3512 | - | // currently highlighted command and run it. That way typing | |
| 3513 | - | // "/lo" + enter picks whichever of /login or /logout is selected | |
| 3514 | - | // in the popup instead of submitting "/lo" as unknown. Also | |
| 3515 | - | // clear the editor so the command doesn't linger after the | |
| 3516 | - | // dialog opens/closes. | |
| 3517 | - | if name := i.suggest.Selection(i.ed.Value()); name != "" { | |
| 3518 | - | i.ed.Clear() | |
| 3519 | - | i.suggest.Reset() | |
| 3520 | - | return i.runSlash(ctx, name) | |
| 3521 | - | } | |
| 3522 | - | case tui.KeyEsc: | |
| 3523 | - | i.ed.Clear() | |
| 3524 | - | i.suggest.Reset() | |
| 3525 | - | return false | |
| 3526 | - | } | |
| 3527 | - | } | |
| 3528 | - | ||
| 3529 | - | // File suggestions: intercept up/down/tab/enter when the @-popup is visible. | |
| 3530 | - | if i.fileSuggest.Active(i.ed.Value()) { | |
| 3531 | - | switch k.Kind { | |
| 3532 | - | case tui.KeyUp: | |
| 3533 | - | i.fileSuggest.Up() | |
| 3534 | - | return false | |
| 3535 | - | case tui.KeyDown: | |
| 3536 | - | i.fileSuggest.Down() | |
| 3537 | - | return false | |
| 3538 | - | case tui.KeyRight: | |
| 3539 | - | // Open selected directory. The filter the user typed picked | |
| 3540 | - | // that directory at the current level; once we descend it no | |
| 3541 | - | // longer applies to the directory's contents, so clear it. | |
| 3542 | - | // Otherwise typing "@eda" then right would re-filter inside | |
| 3543 | - | // eda/ by "eda" and show nothing. | |
| 3544 | - | if i.fileSuggest.Right() { | |
| 3545 | - | i.clearFileSuggestQuery() | |
| 3546 | - | } | |
| 3547 | - | return false | |
| 3548 | - | case tui.KeyLeft: | |
| 3549 | - | // Go back to parent directory. Clear the filter for the same | |
| 3550 | - | // reason as Right: it was scoped to the level we just left. | |
| 3551 | - | if i.fileSuggest.Left() { | |
| 3552 | - | i.clearFileSuggestQuery() | |
| 3553 | - | } | |
| 3554 | - | return false | |
| 3555 | - | case tui.KeyEnter: | |
| 3556 | - | if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok { | |
| 3557 | - | var chip string | |
| 3558 | - | if entry.isDir { | |
| 3559 | - | chip = "[dir:" + entry.rel + "/]" | |
| 3560 | - | } else { | |
| 3561 | - | chip = "[file:" + entry.rel + "]" | |
| 3562 | - | } | |
| 3563 | - | val := i.ed.Value() | |
| 3564 | - | if idx := strings.LastIndex(val, "@"); idx >= 0 { | |
| 3565 | - | val = val[:idx] | |
| 3566 | - | } | |
| 3567 | - | i.ed.SetValue(val + chip + " ") | |
| 3568 | - | i.fileSuggest.Reset() | |
| 3569 | - | } | |
| 3570 | - | return false | |
| 3571 | - | case tui.KeyEsc: | |
| 3572 | - | val := i.ed.Value() | |
| 3573 | - | if idx := strings.LastIndex(val, "@"); idx >= 0 { | |
| 3574 | - | i.ed.SetValue(val[:idx]) | |
| 3575 | - | } | |
| 3576 | - | i.fileSuggest.Reset() | |
| 3577 | - | return false | |
| 3578 | - | } | |
| 3579 | - | } | |
| 3580 | - | ||
| 3581 | - | // Tab-complete a path token in the editor when no popup is open. | |
| 3582 | - | // Recognises tokens that look like paths (start with ~, /, ./, ../ | |
| 3583 | - | // or contain a slash); shell-style completion expands ~, lists the | |
| 3584 | - | // parent dir, and completes the basename to the longest common | |
| 3585 | - | // prefix. Single match: full replace and trailing / for dirs. | |
| 3586 | - | // No match: no-op. Plain bare words (no slash, no tilde) fall | |
| 3587 | - | // through so Tab keeps its current no-op behaviour outside paths. | |
| 3588 | - | if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | |
| 3589 | - | if i.tryPathTabComplete() { | |
| 3590 | - | return false | |
| 3591 | - | } | |
| 3592 | - | } | |
| 3593 | - | ||
| 3594 | - | if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown { | |
| 3595 | - | i.inputHistoryIndex = -1 | |
| 3596 | - | } | |
| 3597 | - | ||
| 3598 | - | if k.Kind == tui.KeyEsc { | |
| 3599 | - | i.clipboardImages = nil | |
| 3600 | - | } | |
| 3601 | - | if submit := i.ed.HandleKey(k); submit { | |
| 3602 | - | // SubmitValue() expands any [pasted text #N +L lines] | |
| 3603 | - | // placeholders back into their bodies; the raw Value() | |
| 3604 | - | // is only what the user sees on screen. | |
| 3605 | - | text := strings.TrimRight(i.ed.SubmitValue(), "\n") | |
| 3606 | - | // Expand [file:name] and [dir:name/] chips to full paths. | |
| 3607 | - | text = expandFileChips(text, i.cfg.CWD) | |
| 3608 | - | text, images := preparePromptWithClipboardImages(text, i.clipboardImages) | |
| 3609 | - | if text == "" && len(images) == 0 { | |
| 3610 | - | return false | |
| 3611 | - | } | |
| 3612 | - | clearSubmittedInput := func() { | |
| 3613 | - | i.clipboardImages = nil | |
| 3614 | - | i.ed.Clear() | |
| 3615 | - | i.inputHistoryIndex = -1 | |
| 3616 | - | i.suggest.Reset() | |
| 3617 | - | i.fileSuggest.Reset() | |
| 3618 | - | } | |
| 3619 | - | ||
| 3620 | - | // Shell escapes and slash commands are text-only. An image-bearing | |
| 3621 | - | // draft remains a normal provider prompt instead of silently dropping | |
| 3622 | - | // its attachments. | |
| 3623 | - | if len(images) == 0 { | |
| 3624 | - | if cmd, ok := shellEscapeCommand(text); ok { | |
| 3625 | - | clearSubmittedInput() | |
| 3626 | - | i.startShellEscape(ctx, cmd) | |
| 3627 | - | return false | |
| 3628 | - | } | |
| 3629 | - | } | |
| 3630 | - | ||
| 3631 | - | if len(images) == 0 && looksLikeSlashCommand(text) { | |
| 3632 | - | clearSubmittedInput() | |
| 3633 | - | head := text | |
| 3634 | - | rest := "" | |
| 3635 | - | if idx := strings.IndexAny(text, " \t"); idx >= 0 { | |
| 3636 | - | head = text[:idx] | |
| 3637 | - | rest = strings.TrimSpace(text[idx:]) | |
| 3638 | - | } | |
| 3639 | - | if !isKnownSlashCommand(text) { | |
| 3640 | - | // Try extensions before giving up. Extensions register | |
| 3641 | - | // commands by bare name (no leading slash); strip it here. | |
| 3642 | - | extName := strings.TrimPrefix(head, "/") | |
| 3643 | - | if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) { | |
| 3644 | - | go i.invokeExtensionCommand(ctx, extName, rest) | |
| 3645 | - | return false | |
| 3646 | - | } | |
| 3647 | - | i.mu.Lock() | |
| 3648 | - | i.statusErr = "unknown command " + head + " — type /help to see the list" | |
| 3649 | - | i.statusOK = "" | |
| 3650 | - | i.mu.Unlock() | |
| 3651 | - | return false | |
| 3652 | - | } | |
| 3653 | - | // Slash commands run regardless of busy state. Commands that | |
| 3654 | - | // would mutate the transcript or replace the agent (/clear, | |
| 3655 | - | // /compact, /logout, /login, /model, /fork, /session fork) | |
| 3656 | - | // cancel the active turn | |
| 3657 | - | // first and wait for the goroutine to wind down so they don't | |
| 3658 | - | // race with a streaming response. Safe commands (/help, | |
| 3659 | - | // /jump, /sessions, /jail, /unjail, /exit) run immediately | |
| 3660 | - | // without disturbing the active turn. | |
| 3661 | - | if slashCommandCancelsTurn(text) { | |
| 3662 | - | i.cancelAndWaitForIdle() | |
| 3663 | - | } | |
| 3664 | - | return i.runSlash(ctx, text) | |
| 3665 | - | } | |
| 3666 | - | ||
| 3667 | - | if i.agent == nil { | |
| 3668 | - | i.mu.Lock() | |
| 3669 | - | i.statusErr = "not logged in. type /login first." | |
| 3670 | - | i.mu.Unlock() | |
| 3671 | - | return false | |
| 3672 | - | } | |
| 3673 | - | // Mirror the user's typed prompt into the paired Telegram | |
| 3674 | - | // chat (when the bridge is active) so the Telegram thread | |
| 3675 | - | // stays a complete record of the session, not just the half | |
| 3676 | - | // that originated on the phone. On a goroutine so the | |
| 3677 | - | // network write doesn't delay the local turn. | |
| 3678 | - | if i.telegramBridge != nil && i.telegramBridge.Active() { | |
| 3679 | - | go i.telegramBridge.OnUserTyped(text) | |
| 3680 | - | } | |
| 3681 | - | i.maybeStartSessionTitle(ctx, text) | |
| 3682 | - | // If a turn is already in flight, queue this prompt inside the | |
| 3683 | - | // agent loop so it is delivered at the next safe model-call | |
| 3684 | - | // boundary instead of waiting for the whole run to finish. | |
| 3685 | - | i.mu.Lock() | |
| 3686 | - | busy := i.busy | |
| 3687 | - | compacting := i.compacting | |
| 3688 | - | ag := i.agent | |
| 3689 | - | i.mu.Unlock() | |
| 3690 | - | if busy { | |
| 3691 | - | if len(images) > 0 { | |
| 3692 | - | i.mu.Lock() | |
| 3693 | - | i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish" | |
| 3694 | - | i.mu.Unlock() | |
| 3695 | - | i.invalidate() | |
| 3696 | - | return false | |
| 3697 | - | } | |
| 3698 | - | clearSubmittedInput() | |
| 3699 | - | var handoff json.RawMessage | |
| 3700 | - | var persistHandoff bool | |
| 3701 | - | if ag != nil && !compacting { | |
| 3702 | - | i.mu.Lock() | |
| 3703 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 3704 | - | ag.QueueMessage(text) | |
| 3705 | - | i.mu.Unlock() | |
| 3706 | - | } else { | |
| 3707 | - | i.mu.Lock() | |
| 3708 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 3709 | - | i.queued = append(i.queued, text) | |
| 3710 | - | i.mu.Unlock() | |
| 3711 | - | } | |
| 3712 | - | if persistHandoff { | |
| 3713 | - | i.persistCompactHandoff(handoff) | |
| 3714 | - | } | |
| 3715 | - | i.invalidate() | |
| 3716 | - | return false | |
| 3717 | - | } | |
| 3718 | - | clearSubmittedInput() | |
| 3719 | - | i.startTurnWithImages(ctx, text, images) | |
| 3720 | - | } | |
| 3721 | - | return false | |
| 3722 | - | } | |
| 3723 | - | ||
| 3724 | - | func (i *Interactive) pasteClipboard(ctx context.Context) { | |
| 3725 | - | image, ok, _ := tui.ReadClipboardImage(ctx) | |
| 3726 | - | if ok { | |
| 3727 | - | i.mu.Lock() | |
| 3728 | - | marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1) | |
| 3729 | - | i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{ | |
| 3730 | - | Marker: marker, | |
| 3731 | - | Image: provider.ImageBlock{MimeType: image.MimeType, Data: image.Data}, | |
| 3732 | - | }) | |
| 3733 | - | i.ed.Insert(marker + " ") | |
| 3734 | - | i.statusOK = "" | |
| 3735 | - | i.statusErr = "" | |
| 3736 | - | i.mu.Unlock() | |
| 3737 | - | return | |
| 3738 | - | } | |
| 3739 | - | key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText) | |
| 3740 | - | i.mu.Lock() | |
| 3741 | - | defer i.mu.Unlock() | |
| 3742 | - | if err != nil { | |
| 3743 | - | i.statusErr = "clipboard paste failed: " + err.Error() | |
| 3744 | - | i.statusOK = "" | |
| 3745 | - | return | |
| 3746 | - | } | |
| 3747 | - | if pastedText { | |
| 3748 | - | i.ed.HandleKey(key) | |
| 3749 | - | i.statusErr = "" | |
| 3750 | - | i.statusOK = "" | |
| 3751 | - | return | |
| 3752 | - | } | |
| 3753 | - | i.statusErr = "clipboard does not contain text or an image" | |
| 3754 | - | i.statusOK = "" | |
| 3755 | - | } | |
| 3756 | - | ||
| 3757 | - | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | |
| 3758 | - | if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown { | |
| 3759 | - | return false | |
| 3760 | - | } | |
| 3761 | - | // Do not steal normal vertical cursor movement. History browsing can only | |
| 3762 | - | // start from an empty editor; once active, Up/Down keep walking | |
| 3763 | - | // the ring so repeated presses work even though the editor now | |
| 3764 | - | // contains the selected historical prompt. | |
| 3765 | - | if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() { | |
| 3766 | - | return false | |
| 3767 | - | } | |
| 3768 | - | hist := i.inputHistory() | |
| 3769 | - | if len(hist) == 0 { | |
| 3770 | - | return false | |
| 3771 | - | } | |
| 3772 | - | ||
| 3773 | - | if i.inputHistoryIndex < 0 { | |
| 3774 | - | // Start just after the newest item so Up lands on the most | |
| 3775 | - | // recent user prompt. A lone Down from an empty editor is not | |
| 3776 | - | // history navigation; let the caller fall through to normal UI | |
| 3777 | - | // behavior instead. | |
| 3778 | - | if k.Kind != tui.KeyUp { | |
| 3779 | - | return false | |
| 3780 | - | } | |
| 3781 | - | i.inputHistoryIndex = len(hist) | |
| 3782 | - | } | |
| 3783 | - | ||
| 3784 | - | switch k.Kind { | |
| 3785 | - | case tui.KeyUp: | |
| 3786 | - | if i.inputHistoryIndex > 0 { | |
| 3787 | - | i.inputHistoryIndex-- | |
| 3788 | - | } | |
| 3789 | - | case tui.KeyDown: | |
| 3790 | - | if i.inputHistoryIndex < len(hist) { | |
| 3791 | - | i.inputHistoryIndex++ | |
| 3792 | - | } | |
| 3793 | - | } | |
| 3794 | - | ||
| 3795 | - | if i.inputHistoryIndex >= len(hist) { | |
| 3796 | - | i.ed.Clear() | |
| 3797 | - | } else { | |
| 3798 | - | i.ed.SetValue(hist[i.inputHistoryIndex]) | |
| 3799 | - | } | |
| 3800 | - | return true | |
| 3801 | - | } | |
| 3802 | - | ||
| 3803 | - | func (i *Interactive) inputHistory() []string { | |
| 3804 | - | if i.agent == nil { | |
| 3805 | - | return nil | |
| 3806 | - | } | |
| 3807 | - | msgs := i.agent.Messages() | |
| 3808 | - | hist := make([]string, 0, len(msgs)) | |
| 3809 | - | for _, m := range msgs { | |
| 3810 | - | if !isForkableUserMessage(m) { | |
| 3811 | - | continue | |
| 3812 | - | } | |
| 3813 | - | text := userMessageText(m) | |
| 3814 | - | if strings.TrimSpace(text) == "" { | |
| 3815 | - | continue | |
| 3816 | - | } | |
| 3817 | - | hist = append(hist, text) | |
| 3818 | - | } | |
| 3819 | - | return hist | |
| 3820 | - | } | |
| 3821 | - | ||
| 3822 | - | func userMessageText(m provider.Message) string { | |
| 3823 | - | var sb strings.Builder | |
| 3824 | - | for _, c := range m.Content { | |
| 3825 | - | if tb, ok := c.(provider.TextBlock); ok { | |
| 3826 | - | if sb.Len() > 0 { | |
| 3827 | - | sb.WriteByte('\n') | |
| 3828 | - | } | |
| 3829 | - | sb.WriteString(tb.Text) | |
| 3830 | - | } | |
| 3831 | - | } | |
| 3832 | - | return sb.String() | |
| 3833 | - | } | |
| 3834 | - | ||
| 3835 | - | // invokeExtensionCommand fires an extension-registered slash command | |
| 3836 | - | // in a background goroutine, awaits the response, and applies the | |
| 3837 | - | // requested action (prompt / insert / display / noop). Errors and | |
| 3838 | - | // timeouts surface as a status_err line. | |
| 3839 | - | ||
| 3840 | - | func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) { | |
| 3841 | - | resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second) | |
| 3842 | - | if err != nil { | |
| 3843 | - | i.mu.Lock() | |
| 3844 | - | i.statusErr = "extension /" + name + ": " + err.Error() | |
| 3845 | - | i.statusOK = "" | |
| 3846 | - | i.mu.Unlock() | |
| 3847 | - | i.invalidate() | |
| 3848 | - | return | |
| 3849 | - | } | |
| 3850 | - | if resp.Error != "" { | |
| 3851 | - | i.mu.Lock() | |
| 3852 | - | i.statusErr = "extension /" + name + ": " + resp.Error | |
| 3853 | - | i.statusOK = "" | |
| 3854 | - | i.mu.Unlock() | |
| 3855 | - | i.invalidate() | |
| 3856 | - | return | |
| 3857 | - | } | |
| 3858 | - | switch resp.Action { | |
| 3859 | - | case "open_panel": | |
| 3860 | - | if resp.OpenPanel != nil { | |
| 3861 | - | extName := name | |
| 3862 | - | if i.cfg.Extensions != nil { | |
| 3863 | - | if owner := i.cfg.Extensions.CommandOwner(name); owner != "" { | |
| 3864 | - | extName = owner | |
| 3865 | - | } | |
| 3866 | - | } | |
| 3867 | - | i.OpenPanel(extName, *resp.OpenPanel) | |
| 3868 | - | } | |
| 3869 | - | case "prompt": | |
| 3870 | - | if strings.TrimSpace(resp.Prompt) == "" { | |
| 3871 | - | return | |
| 3872 | - | } | |
| 3873 | - | i.startTurn(i.runCtx, resp.Prompt) | |
| 3874 | - | case "insert": | |
| 3875 | - | i.ed.Insert(resp.Insert) | |
| 3876 | - | i.invalidate() | |
| 3877 | - | case "display": | |
| 3878 | - | i.appendExtensionNote(name, resp.Display, "info") | |
| 3879 | - | case "noop", "": | |
| 3880 | - | // nothing | |
| 3881 | - | default: | |
| 3882 | - | i.mu.Lock() | |
| 3883 | - | i.statusErr = "extension /" + name + ": unknown action " + resp.Action | |
| 3884 | - | i.mu.Unlock() | |
| 3885 | - | i.invalidate() | |
| 3886 | - | } | |
| 3887 | - | } | |
| 3888 | - | ||
| 3889 | - | // appendExtensionNote renders an extension-originated note in the | |
| 3890 | - | // chat. Levels: "info" (muted), "warn" (warning), "error" (error), | |
| 3891 | - | // "success" (tool/ok green). | |
| 3892 | - | func (i *Interactive) appendExtensionNote(extName, msg, level string) { | |
| 3893 | - | if msg == "" { | |
| 3894 | - | return | |
| 3895 | - | } | |
| 3896 | - | i.mu.Lock() | |
| 3897 | - | defer i.mu.Unlock() | |
| 3898 | - | color := i.cfg.Theme.Muted | |
| 3899 | - | switch level { | |
| 3900 | - | case "warn": | |
| 3901 | - | color = i.cfg.Theme.Warning | |
| 3902 | - | case "error": | |
| 3903 | - | color = i.cfg.Theme.Error | |
| 3904 | - | case "success": | |
| 3905 | - | color = i.cfg.Theme.Tool | |
| 3906 | - | } | |
| 3907 | - | prefix := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, "["+extName+"] ") | |
| 3908 | - | for _, line := range strings.Split(msg, "\n") { | |
| 3909 | - | i.statusOK = "" // clear any stale ok | |
| 3910 | - | i.statusErr = "" | |
| 3911 | - | i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FGColor(color, line)) | |
| 3912 | - | } | |
| 3913 | - | // Extension slash commands complete asynchronously. If their result is | |
| 3914 | - | // displayed while confirmation is waiting, keep Esc assigned to the note | |
| 3915 | - | // until it is dismissed rather than letting it answer the tool call. | |
| 3916 | - | i.confirmDialog.Blur() | |
| 3917 | - | } | |
| 3918 | - | ||
| 3919 | - | // HostHooks implementation for the extension manager. The manager | |
| 3920 | - | // holds an interface, not a concrete *Interactive, so these methods | |
| 3921 | - | // are the only thing the manager sees. | |
| 3922 | - | ||
| 3923 | - | // Notify is the manager's NotifyFromExt entry point. | |
| 3924 | - | func (i *Interactive) Notify(extName, level, message string) { | |
| 3925 | - | i.appendExtensionNote(extName, message, level) | |
| 3926 | - | i.invalidate() | |
| 3927 | - | } | |
| 3928 | - | ||
| 3929 | - | // Alert is the manager's AlertFromExt entry point. Alerts are emitted | |
| 3930 | - | // through the same terminal writer as redraws while holding the TUI mutex, | |
| 3931 | - | // so a BEL cannot be interleaved with a frame update. | |
| 3932 | - | func (i *Interactive) Alert(_ string, alert extproto.AlertRequest) { | |
| 3933 | - | i.mu.Lock() | |
| 3934 | - | i.emitAlertLocked(alert) | |
| 3935 | - | i.mu.Unlock() | |
| 3936 | - | } | |
| 3937 | - | ||
| 3938 | - | func terminalAlertsEnabled(enabled *bool) bool { | |
| 3939 | - | return enabled == nil || *enabled | |
| 3940 | - | } | |
| 3941 | - | ||
| 3942 | - | // emitAlertLocked applies the shared terminal-alert policy. The caller must | |
| 3943 | - | // hold i.mu because terminal writes share the renderer's output boundary. | |
| 3944 | - | func (i *Interactive) emitAlertLocked(alert extproto.AlertRequest) { | |
| 3945 | - | if alert.Kind != extproto.AlertKindBell || !terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) || i.cfg.Terminal == nil { | |
| 3946 | - | return | |
| 3947 | - | } | |
| 3948 | - | _ = tui.WriteBell(i.cfg.Terminal) | |
| 3949 | - | } | |
| 3950 | - | ||
| 3951 | - | // scheduleMainAlert defers a main-session alert until the next redraw | |
| 3952 | - | // commits the final frame. Keeping this deferred even when no paced text is | |
| 3953 | - | // pending avoids racing the pacer's final state transition. | |
| 3954 | - | func (i *Interactive) scheduleMainAlert(reason string) { | |
| 3955 | - | if reason == "" { | |
| 3956 | - | return | |
| 3957 | - | } | |
| 3958 | - | i.mu.Lock() | |
| 3959 | - | i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason} | |
| 3960 | - | i.mu.Unlock() | |
| 3961 | - | i.invalidate() | |
| 3962 | - | } | |
| 3963 | - | ||
| 3964 | - | // ClearNotes removes every note line owned by extName from the | |
| 3965 | - | // bottom-sticky ext-notes block. Extensions use this to retract a | |
| 3966 | - | // transient status line (e.g. an approval prompt) once it no longer | |
| 3967 | - | // applies, instead of leaving it stacked forever. Notes from other | |
| 3968 | - | // extensions and internal notes (auto-compact) are left untouched. | |
| 3969 | - | func (i *Interactive) ClearNotes(extName string) { | |
| 3970 | - | marker := "[" + extName + "] " | |
| 3971 | - | i.mu.Lock() | |
| 3972 | - | if len(i.extNotes) == 0 { | |
| 3973 | - | i.mu.Unlock() | |
| 3974 | - | return | |
| 3975 | - | } | |
| 3976 | - | kept := i.extNotes[:0:0] | |
| 3977 | - | changed := false | |
| 3978 | - | for _, line := range i.extNotes { | |
| 3979 | - | if strings.Contains(line, marker) { | |
| 3980 | - | changed = true | |
| 3981 | - | continue | |
| 3982 | - | } | |
| 3983 | - | kept = append(kept, line) | |
| 3984 | - | } | |
| 3985 | - | if changed { | |
| 3986 | - | i.extNotes = kept | |
| 3987 | - | } | |
| 3988 | - | i.mu.Unlock() | |
| 3989 | - | if changed { | |
| 3990 | - | i.invalidate() | |
| 3991 | - | } | |
| 3992 | - | } | |
| 3993 | - | ||
| 3994 | - | // Submit feeds text through the agent loop as if the user had typed it. | |
| 3995 | - | func (i *Interactive) Submit(text string) { | |
| 3996 | - | if cmd, ok := shellEscapeCommand(text); ok { | |
| 3997 | - | i.startShellEscape(i.runCtx, cmd) | |
| 3998 | - | return | |
| 3999 | - | } | |
| 4000 | - | parent := i.runCtx | |
| 4001 | - | if parent == nil { | |
| 4002 | - | parent = context.Background() | |
| 4003 | - | } | |
| 4004 | - | i.mu.Lock() | |
| 4005 | - | awaitingStartupPre := i.awaitingStartupPre | |
| 4006 | - | i.mu.Unlock() | |
| 4007 | - | if !awaitingStartupPre { | |
| 4008 | - | i.maybeStartSessionTitle(parent, text) | |
| 4009 | - | } | |
| 4010 | - | i.startTurn(parent, text) | |
| 4011 | - | } | |
| 4012 | - | ||
| 4013 | - | // completeStartupPre applies deferred InitialInput after entry.pre finishes. | |
| 4014 | - | // When AutoSubmitInitial was set, the deferred prompt is submitted; otherwise | |
| 4015 | - | // it only pre-fills the editor (CLI-supplied prompts). | |
| 4016 | - | func (i *Interactive) completeStartupPre() { | |
| 4017 | - | i.mu.Lock() | |
| 4018 | - | if !i.awaitingStartupPre { | |
| 4019 | - | i.mu.Unlock() | |
| 4020 | - | return | |
| 4021 | - | } | |
| 4022 | - | i.awaitingStartupPre = false | |
| 4023 | - | deferred := i.deferredInitialInput | |
| 4024 | - | auto := i.autoSubmitDeferred | |
| 4025 | - | i.deferredInitialInput = "" | |
| 4026 | - | i.autoSubmitDeferred = false | |
| 4027 | - | onDone := i.cfg.OnStartupPreDone | |
| 4028 | - | i.mu.Unlock() | |
| 4029 | - | if onDone != nil { | |
| 4030 | - | onDone() | |
| 4031 | - | } | |
| 4032 | - | i.startupPreDone <- startupPreResult{deferred: deferred, autoSubmit: auto} | |
| 4033 | - | i.invalidate() | |
| 4034 | - | } | |
| 4035 | - | ||
| 4036 | - | // applyStartupPreResult runs on the TUI event loop so the editor remains | |
| 4037 | - | // single-threaded. Input entered while resources were reloading wins over the | |
| 4038 | - | // deferred prefill rather than being overwritten. | |
| 4039 | - | func (i *Interactive) applyStartupPreResult(result startupPreResult) { | |
| 4040 | - | if result.deferred == "" { | |
| 4041 | - | return | |
| 4042 | - | } | |
| 4043 | - | if result.autoSubmit { | |
| 4044 | - | i.Submit(result.deferred) | |
| 4045 | - | return | |
| 4046 | - | } | |
| 4047 | - | if i.ed.IsEmpty() { | |
| 4048 | - | i.ed.SetValue(result.deferred) | |
| 4049 | - | } | |
| 4050 | - | } | |
| 4051 | - | ||
| 4052 | - | // ApplySessionAgent swaps the live agent after a validated session resume. | |
| 4053 | - | // Unlike ApplyChangedCWD it does not mutate cwd-scoped resources or startup | |
| 4054 | - | // context; the session loader is changing the transcript/provider only. | |
| 4055 | - | func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model string) { | |
| 4056 | - | i.ApplySessionAgentWithCompactHandoff(ag, providerName, model, nil) | |
| 4057 | - | } | |
| 4058 | - | ||
| 4059 | - | func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, providerName, model string, compactHandoff json.RawMessage) { | |
| 4060 | - | if ag == nil { | |
| 4061 | - | return | |
| 4062 | - | } | |
| 4063 | - | i.agentMu.Lock() | |
| 4064 | - | defer i.agentMu.Unlock() | |
| 4065 | - | i.mu.Lock() | |
| 4066 | - | i.prepareReplacementAgentLocked(ag) | |
| 4067 | - | i.compactContinuation = decodeCompactHandoff(compactHandoff) | |
| 4068 | - | i.agent = ag | |
| 4069 | - | i.cfg.Provider = providerName | |
| 4070 | - | i.cfg.Model = model | |
| 4071 | - | i.view.Messages = filterHiddenTranscriptMessages(ag.Messages()) | |
| 4072 | - | i.cumUsage = ag.Cost() | |
| 4073 | - | last := ag.LastTurnUsage() | |
| 4074 | - | i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens | |
| 4075 | - | if len(i.view.Messages) > initialResumeTailLimit { | |
| 4076 | - | i.view.TailLimit = initialResumeTailLimit | |
| 4077 | - | } else { | |
| 4078 | - | i.view.TailLimit = 0 | |
| 4079 | - | } | |
| 4080 | - | i.view.InvalidateRenderCache() | |
| 4081 | - | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 4082 | - | i.toolOrder = nil | |
| 4083 | - | i.toolGate = map[string]int{} | |
| 4084 | - | i.helpBlock = nil | |
| 4085 | - | i.extNotes = nil | |
| 4086 | - | i.parkedTurn = 0 | |
| 4087 | - | i.parkedTotal = 0 | |
| 4088 | - | i.mu.Unlock() | |
| 4089 | - | i.invalidate() | |
| 4090 | - | } | |
| 4091 | - | ||
| 4092 | - | // SetSubagentSessionScope redraws after a host session transition. Resident | |
| 4093 | - | // children retain their explicit parent session in their durable journal. | |
| 4094 | - | func (i *Interactive) SetSubagentSessionScope(_ string) { | |
| 4095 | - | i.invalidate() | |
| 4096 | - | } | |
| 4097 | - | ||
| 4098 | - | // ApplyChangedCWD is called by hosts after a successful /cd hook that do | |
| 4099 | - | // not provide startup context metadata. | |
| 4100 | - | func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) { | |
| 4101 | - | i.applyChangedCWD(ag, provider, model, cwd, nil) | |
| 4102 | - | } | |
| 4103 | - | ||
| 4104 | - | // ApplyChangedCWDWithStartupContext swaps a rebuilt agent and its display-only | |
| 4105 | - | // startup context into the running TUI after a successful /cd hook. | |
| 4106 | - | func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) { | |
| 4107 | - | i.applyChangedCWD(ag, provider, model, cwd, startupContextPaths) | |
| 4108 | - | } | |
| 4109 | - | ||
| 4110 | - | func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) { | |
| 4111 | - | home, _ := os.UserHomeDir() | |
| 4112 | - | profiles, _ := subagents.Discover(cwd, home) | |
| 4113 | - | subagentsAddendum := subagents.SystemPromptAddendum(profiles) | |
| 4114 | - | ||
| 4115 | - | i.agentMu.Lock() | |
| 4116 | - | defer i.agentMu.Unlock() | |
| 4117 | - | i.mu.Lock() | |
| 4118 | - | i.prepareReplacementAgentLocked(ag) | |
| 4119 | - | i.resetCompactContinuationLocked() | |
| 4120 | - | i.agent = ag | |
| 4121 | - | i.cfg.CWD = cwd | |
| 4122 | - | i.cfg.SubagentsSystemAddendum = subagentsAddendum | |
| 4123 | - | i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked()) | |
| 4124 | - | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | |
| 4125 | - | i.view.StartupContextPaths = nil | |
| 4126 | - | i.view.InvalidateRenderCache() | |
| 4127 | - | if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup { | |
| 4128 | - | i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...) | |
| 4129 | - | } | |
| 4130 | - | // Re-report the working directory to the terminal so "new tab here" | |
| 4131 | - | // tracks the /cd change (OSC 7, see issue #38). | |
| 4132 | - | if i.cfg.Terminal != nil { | |
| 4133 | - | if seq := tui.ReportCWD(cwd); seq != "" { | |
| 4134 | - | _, _ = i.cfg.Terminal.Write([]byte(seq)) | |
| 4135 | - | } | |
| 4136 | - | } | |
| 4137 | - | i.cfg.Provider = provider | |
| 4138 | - | i.cfg.Model = model | |
| 4139 | - | titleCancel := i.titleCancel | |
| 4140 | - | i.titleCancel = nil | |
| 4141 | - | i.titleVersion++ | |
| 4142 | - | i.sessionTitle = "" | |
| 4143 | - | i.titleRealPromptSeen = false | |
| 4144 | - | i.titleGenerationStarted = false | |
| 4145 | - | i.writeTerminalTitleLocked("") | |
| 4146 | - | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 4147 | - | i.toolOrder = nil | |
| 4148 | - | i.toolGate = map[string]int{} | |
| 4149 | - | i.helpBlock = nil | |
| 4150 | - | i.parkedTurn = 0 | |
| 4151 | - | i.statusErr = "" | |
| 4152 | - | i.mu.Unlock() | |
| 4153 | - | if titleCancel != nil { | |
| 4154 | - | titleCancel() | |
| 4155 | - | } | |
| 4156 | - | i.fileSuggest.Reset() | |
| 4157 | - | i.fileSuggest.SetCWD(cwd) | |
| 4158 | - | i.invalidate() | |
| 4159 | - | } | |
| 4160 | - | ||
| 4161 | - | // SubmitSlash runs text as a slash command in the TUI as if the user | |
| 4162 | - | // had typed it. text must start with '/' — callers that hand it | |
| 4163 | - | // plain prose silently get a no-op so a misbehaving extension can't | |
| 4164 | - | // run a stray prompt through this path. Read-only commands run in | |
| 4165 | - | // place; commands that would mutate the transcript or replace the | |
| 4166 | - | // agent cancel the active turn first via the same path the editor | |
| 4167 | - | // uses for typed slash commands. | |
| 4168 | - | func (i *Interactive) SubmitSlash(text string) { | |
| 4169 | - | i.mu.Lock() | |
| 4170 | - | i.doubleEscape.Reset() | |
| 4171 | - | i.mu.Unlock() | |
| 4172 | - | text = strings.TrimSpace(text) | |
| 4173 | - | if !strings.HasPrefix(text, "/") { | |
| 4174 | - | return | |
| 4175 | - | } | |
| 4176 | - | if slashCommandCancelsTurn(text) { | |
| 4177 | - | i.cancelAndWaitForIdle() | |
| 4178 | - | } | |
| 4179 | - | i.runSlash(i.runCtx, text) | |
| 4180 | - | i.invalidate() | |
| 4181 | - | } | |
| 4182 | - | ||
| 4183 | - | // SubmitOrQueue runs text immediately if the agent is idle, or | |
| 4184 | - | // appends it to the pending queue if a turn is already in flight. | |
| 4185 | - | // Used by the telegram bridge (and by the editor submit path) so | |
| 4186 | - | // both input sources share the same "queue behind an active turn" | |
| 4187 | - | // semantics. Images are ignored for now — only the text prompt is | |
| 4188 | - | // forwarded — because the queued-prompt path is text-only; a | |
| 4189 | - | // follow-up can expand the queue entry to carry images. | |
| 4190 | - | func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) { | |
| 4191 | - | i.submitOrQueue(text, images, true) | |
| 4192 | - | } | |
| 4193 | - | ||
| 4194 | - | func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, userInput bool) { | |
| 4195 | - | if cmd, ok := shellEscapeCommand(text); ok { | |
| 4196 | - | i.startShellEscape(i.runCtx, cmd) | |
| 4197 | - | return | |
| 4198 | - | } | |
| 4199 | - | i.mu.Lock() | |
| 4200 | - | if i.agent == nil { | |
| 4201 | - | i.statusErr = "not logged in. type /login first." | |
| 4202 | - | i.mu.Unlock() | |
| 4203 | - | i.invalidate() | |
| 4204 | - | return | |
| 4205 | - | } | |
| 4206 | - | i.mu.Unlock() | |
| 4207 | - | if userInput && i.cfg.EnsureMission != nil { | |
| 4208 | - | if err := i.cfg.EnsureMission(text); err != nil { | |
| 4209 | - | i.ReportError(fmt.Errorf("persist mission: %w", err)) | |
| 4210 | - | return | |
| 4211 | - | } | |
| 4212 | - | } | |
| 4213 | - | // User input is an orchestration event, not merely a queue append. When | |
| 4214 | - | // the manager is dormant behind a sealed worker wave, the coordinator makes | |
| 4215 | - | // this the next manager turn and carries any completed worker summary with it. | |
| 4216 | - | if userInput && i.coordinatorAcceptsUserInput() { | |
| 4217 | - | if actions := i.applyCoordinator(orchestration.Event{Kind: orchestration.EventUserInput, Text: text}); len(actions) != 0 { | |
| 4218 | - | i.executeCoordinatorActions(actions) | |
| 4219 | - | return | |
| 4220 | - | } | |
| 4221 | - | } | |
| 4222 | - | i.maybeStartSessionTitle(i.runCtx, text) | |
| 4223 | - | i.mu.Lock() | |
| 4224 | - | if i.busy { | |
| 4225 | - | // Queue text only; images are dropped for queued prompts. Keep the | |
| 4226 | - | // interactive mutex held through Agent.QueueMessage so turn teardown | |
| 4227 | - | // cannot inspect the agent queue between the busy check and enqueue. | |
| 4228 | - | // Compaction uses the host queue because it has no active agent loop | |
| 4229 | - | // to drain Agent.QueueMessage entries. | |
| 4230 | - | var handoff json.RawMessage | |
| 4231 | - | var persistHandoff bool | |
| 4232 | - | if i.agent != nil && !i.compacting { | |
| 4233 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 4234 | - | i.agent.QueueMessage(text) | |
| 4235 | - | } else { | |
| 4236 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 4237 | - | i.queued = append(i.queued, text) | |
| 4238 | - | } | |
| 4239 | - | i.mu.Unlock() | |
| 4240 | - | if persistHandoff { | |
| 4241 | - | i.persistCompactHandoff(handoff) | |
| 4242 | - | } | |
| 4243 | - | i.invalidate() | |
| 4244 | - | return | |
| 4245 | - | } | |
| 4246 | - | i.mu.Unlock() | |
| 4247 | - | i.startTurnWithImages(i.runCtx, text, images) | |
| 4248 | - | } | |
| 4249 | - | ||
| 4250 | - | // CancelTurn aborts the active turn if one is running. Used by the | |
| 4251 | - | // telegram bridge when the paired user sends /stop. | |
| 4252 | - | // ChangelogVersion returns the version string of the changelog | |
| 4253 | - | // currently shown (or last shown). Used by the dismiss callback | |
| 4254 | - | // to store the correct version for dev builds. | |
| 4255 | - | func (i *Interactive) ChangelogVersion() string { | |
| 4256 | - | if i.changelogDialog != nil { | |
| 4257 | - | return i.changelogDialog.version | |
| 4258 | - | } | |
| 4259 | - | return "" | |
| 4260 | - | } | |
| 4261 | - | ||
| 4262 | - | func (i *Interactive) CancelTurn() { | |
| 4263 | - | i.mu.Lock() | |
| 4264 | - | cancel := i.cancelTurn | |
| 4265 | - | i.mu.Unlock() | |
| 4266 | - | if cancel != nil { | |
| 4267 | - | i.cancelCoordinator() | |
| 4268 | - | ||
| 4269 | - | i.mu.Lock() | |
| 4270 | - | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 4271 | - | i.mu.Unlock() | |
| 4272 | - | cancel() | |
| 4273 | - | i.confirmDialog.CancelAll("turn cancelled") | |
| 4274 | - | if persistHandoff { | |
| 4275 | - | i.persistCompactHandoff(handoff) | |
| 4276 | - | } | |
| 4277 | - | } | |
| 4278 | - | } | |
| 4279 | - | ||
| 4280 | - | // Insert places text at the cursor in the editor. | |
| 4281 | - | func (i *Interactive) Insert(text string) { | |
| 4282 | - | i.ed.Insert(text) | |
| 4283 | - | i.invalidate() | |
| 4284 | - | } | |
| 4285 | - | ||
| 4286 | - | // Display appends a styled note from extName to the chat without a | |
| 4287 | - | // model call. | |
| 4288 | - | func (i *Interactive) Display(extName, text string) { | |
| 4289 | - | i.appendExtensionNote(extName, text, "info") | |
| 4290 | - | i.invalidate() | |
| 4291 | - | } | |
| 4292 | - | ||
| 4293 | - | // ReportError surfaces a host-side error in the interactive status area. | |
| 4294 | - | func (i *Interactive) ReportError(err error) { | |
| 4295 | - | if err == nil { | |
| 4296 | - | return | |
| 4297 | - | } | |
| 4298 | - | i.mu.Lock() | |
| 4299 | - | i.statusOK = "" | |
| 4300 | - | i.statusErr = err.Error() | |
| 4301 | - | i.mu.Unlock() | |
| 4302 | - | i.invalidate() | |
| 4303 | - | } | |
| 4304 | - | ||
| 4305 | - | // SetStatus replaces one persistent status item owned by an extension. | |
| 4306 | - | func (i *Interactive) SetStatus(extName, key, level, text string) { | |
| 4307 | - | if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" { | |
| 4308 | - | return | |
| 4309 | - | } | |
| 4310 | - | i.mu.Lock() | |
| 4311 | - | if i.extStatuses == nil { | |
| 4312 | - | i.extStatuses = map[string]map[string]extensionStatus{} | |
| 4313 | - | } | |
| 4314 | - | if strings.TrimSpace(text) == "" { | |
| 4315 | - | if items := i.extStatuses[extName]; items != nil { | |
| 4316 | - | delete(items, key) | |
| 4317 | - | if len(items) == 0 { | |
| 4318 | - | delete(i.extStatuses, extName) | |
| 4319 | - | } | |
| 4320 | - | } | |
| 4321 | - | } else { | |
| 4322 | - | if i.extStatuses[extName] == nil { | |
| 4323 | - | i.extStatuses[extName] = map[string]extensionStatus{} | |
| 4324 | - | } | |
| 4325 | - | i.extStatuses[extName][key] = extensionStatus{Level: level, Text: text} | |
| 4326 | - | } | |
| 4327 | - | i.mu.Unlock() | |
| 4328 | - | i.invalidate() | |
| 4329 | - | } | |
| 4330 | - | ||
| 4331 | - | // SetWidget replaces one persistent widget owned by an extension. | |
| 4332 | - | func (i *Interactive) SetWidget(extName, id, position, title string, lines []string) { | |
| 4333 | - | if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" { | |
| 4334 | - | return | |
| 4335 | - | } | |
| 4336 | - | position = extproto.NormalizeWidgetPosition(position) | |
| 4337 | - | i.mu.Lock() | |
| 4338 | - | if i.extWidgets == nil { | |
| 4339 | - | i.extWidgets = map[string]map[string]extensionWidget{} | |
| 4340 | - | } | |
| 4341 | - | if i.extWidgets[extName] == nil { | |
| 4342 | - | i.extWidgets[extName] = map[string]extensionWidget{} | |
| 4343 | - | } | |
| 4344 | - | i.extWidgets[extName][id] = extensionWidget{ | |
| 4345 | - | Position: position, | |
| 4346 | - | Title: title, | |
| 4347 | - | Lines: append([]string(nil), lines...), | |
| 4348 | - | } | |
| 4349 | - | i.mu.Unlock() | |
| 4350 | - | i.invalidate() | |
| 4351 | - | } | |
| 4352 | - | ||
| 4353 | - | // ClearWidget removes one persistent widget owned by an extension. | |
| 4354 | - | func (i *Interactive) ClearWidget(extName, id string) { | |
| 4355 | - | i.mu.Lock() | |
| 4356 | - | if items := i.extWidgets[extName]; items != nil { | |
| 4357 | - | delete(items, id) | |
| 4358 | - | if len(items) == 0 { | |
| 4359 | - | delete(i.extWidgets, extName) | |
| 4360 | - | } | |
| 4361 | - | } | |
| 4362 | - | i.mu.Unlock() | |
| 4363 | - | i.invalidate() | |
| 4364 | - | } | |
| 4365 | - | ||
| 4366 | - | // ClearExtensionChrome removes every persistent UI item owned by an | |
| 4367 | - | // extension that has exited. This keeps crashes, disable/reload operations, | |
| 4368 | - | // and startup failures from leaving stale widgets beside the transcript. | |
| 4369 | - | func (i *Interactive) ClearExtensionChrome(extName string) { | |
| 4370 | - | if strings.TrimSpace(extName) == "" { | |
| 4371 | - | return | |
| 4372 | - | } | |
| 4373 | - | marker := "[" + extName + "] " | |
| 4374 | - | i.mu.Lock() | |
| 4375 | - | changed := false | |
| 4376 | - | if _, ok := i.extStatuses[extName]; ok { | |
| 4377 | - | delete(i.extStatuses, extName) | |
| 4378 | - | changed = true | |
| 4379 | - | } | |
| 4380 | - | if _, ok := i.extWidgets[extName]; ok { | |
| 4381 | - | delete(i.extWidgets, extName) | |
| 4382 | - | changed = true | |
| 4383 | - | } | |
| 4384 | - | if len(i.extNotes) > 0 { | |
| 4385 | - | kept := i.extNotes[:0:0] | |
| 4386 | - | for _, line := range i.extNotes { | |
| 4387 | - | if strings.Contains(line, marker) { | |
| 4388 | - | changed = true | |
| 4389 | - | continue | |
| 4390 | - | } | |
| 4391 | - | kept = append(kept, line) | |
| 4392 | - | } | |
| 4393 | - | i.extNotes = kept | |
| 4394 | - | } | |
| 4395 | - | if i.extPanel != nil && i.extPanel.Active() && i.extPanel.ext == extName { | |
| 4396 | - | i.extPanel.Close() | |
| 4397 | - | changed = true | |
| 4398 | - | } | |
| 4399 | - | i.mu.Unlock() | |
| 4400 | - | if changed { | |
| 4401 | - | i.invalidate() | |
| 4402 | - | } | |
| 4403 | - | } | |
| 4404 | - | ||
| 4405 | - | func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) { | |
| 4406 | - | i.mu.Lock() | |
| 4407 | - | defer i.mu.Unlock() | |
| 4408 | - | i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer) | |
| 4409 | - | i.confirmDialog.Blur() | |
| 4410 | - | if i.cfg.Extensions != nil { | |
| 4411 | - | cols, rows := i.cfg.Terminal.Size() | |
| 4412 | - | _ = cols | |
| 4413 | - | _ = rows | |
| 4414 | - | } | |
| 4415 | - | i.invalidate() | |
| 4416 | - | } | |
| 4417 | - | ||
| 4418 | - | func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) { | |
| 4419 | - | i.mu.Lock() | |
| 4420 | - | defer i.mu.Unlock() | |
| 4421 | - | if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID { | |
| 4422 | - | i.extPanel.Update(title, lines, footer) | |
| 4423 | - | i.invalidate() | |
| 4424 | - | } | |
| 4425 | - | } | |
| 4426 | - | ||
| 4427 | - | func (i *Interactive) ClosePanel(extName, panelID string) { | |
| 4428 | - | i.mu.Lock() | |
| 4429 | - | defer i.mu.Unlock() | |
| 4430 | - | if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID { | |
| 4431 | - | i.extPanel.Close() | |
| 4432 | - | i.invalidate() | |
| 4433 | - | } | |
| 4434 | - | } | |
| 4435 | - | ||
| 4436 | - | func effectiveImageProtocol(override *bool) tui.ImageProtocol { | |
| 4437 | - | detected := tui.DetectImageProtocol() | |
| 4438 | - | if override == nil { | |
| 4439 | - | return detected | |
| 4440 | - | } | |
| 4441 | - | if !*override { | |
| 4442 | - | return tui.ImageProtocolNone | |
| 4443 | - | } | |
| 4444 | - | return detected | |
| 4445 | - | } | |
| 4446 | - | ||
| 4447 | - | func imageProtocolName(p tui.ImageProtocol) string { | |
| 4448 | - | switch p { | |
| 4449 | - | case tui.ImageProtocolKitty: | |
| 4450 | - | return "kitty/ghostty" | |
| 4451 | - | case tui.ImageProtocolITerm2: | |
| 4452 | - | return "iTerm2" | |
| 4453 | - | default: | |
| 4454 | - | return "none" | |
| 4455 | - | } | |
| 4456 | - | } | |
| 4457 | - | ||
| 4458 | - | func onOff(v bool) string { | |
| 4459 | - | if v { | |
| 4460 | - | return "enabled" | |
| 4461 | - | } | |
| 4462 | - | return "disabled" | |
| 4463 | - | } | |
| 4464 | - | ||
| 4465 | - | const modalBackdropDimPercent = 50 | |
| 4466 | - | ||
| 4467 | - | func (i *Interactive) applyInputCursorColor() { | |
| 4468 | - | if i == nil || i.cfg.Terminal == nil { | |
| 4469 | - | return | |
| 4470 | - | } | |
| 4471 | - | color := tui.Color256(15) | |
| 4472 | - | if i.cursorDimmed { | |
| 4473 | - | color = i.cfg.Theme.DimColor(color, modalBackdropDimPercent) | |
| 4474 | - | } | |
| 4475 | - | _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor(color) + tui.CursorShapeBlock())) | |
| 4476 | - | } | |
| 4477 | - | ||
| 4478 | - | // setInputCursorDimmed updates the terminal-owned cursor only when its layer | |
| 4479 | - | // changes. Reapplying cursor controls on every redraw can reset its blink | |
| 4480 | - | // timing before the terminal completes a normal blink cycle. | |
| 4481 | - | func (i *Interactive) setInputCursorDimmed(dimmed bool) { | |
| 4482 | - | if i == nil || i.cursorDimmed == dimmed { | |
| 4483 | - | return | |
| 4484 | - | } | |
| 4485 | - | i.cursorDimmed = dimmed | |
| 4486 | - | i.applyInputCursorColor() | |
| 4487 | - | } | |
| 4488 | - | ||
| 4489 | - | func reasoningSettingOptions(levels []string) []settingsOption { | |
| 4490 | - | descriptions := map[string]string{ | |
| 4491 | - | "": "no reasoning", | |
| 4492 | - | "minimum": "very brief (~1k tokens)", | |
| 4493 | - | "low": "light (~2k tokens)", | |
| 4494 | - | "medium": "moderate (~8k tokens)", | |
| 4495 | - | "high": "deep (~16k tokens)", | |
| 4496 | - | "xhigh": "extra-high effort", | |
| 4497 | - | "max": "unconstrained effort", | |
| 4498 | - | } | |
| 4499 | - | options := make([]settingsOption, 0, len(levels)) | |
| 4500 | - | for _, level := range levels { | |
| 4501 | - | label := level | |
| 4502 | - | if label == "" { | |
| 4503 | - | label = "off" | |
| 4504 | - | } | |
| 4505 | - | options = append(options, settingsOption{value: level, label: label, desc: descriptions[level]}) | |
| 4506 | - | } | |
| 4507 | - | return options | |
| 4508 | - | } | |
| 4509 | - | ||
| 4510 | - | func (i *Interactive) reasoningSettingItem() settingsItem { | |
| 4511 | - | levels := []string{"", "minimum", "low", "medium", "high", "xhigh", "max"} | |
| 4512 | - | reasoning := provider.NormalizeReasoning(i.cfg.Reasoning) | |
| 4513 | - | desc := "reasoning depth for reasoning-capable models" | |
| 4514 | - | hint := "" | |
| 4515 | - | if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil { | |
| 4516 | - | levels = provider.AvailableReasoningLevels(m) | |
| 4517 | - | reasoning = provider.ClampReasoningForModel(m, reasoning) | |
| 4518 | - | if !m.Reasoning { | |
| 4519 | - | hint = "current model does not support reasoning" | |
| 4520 | - | desc += "; current model does not support reasoning" | |
| 4521 | - | } else if len(levels) == 1 { | |
| 4522 | - | hint = "current model has no adjustable reasoning levels" | |
| 4523 | - | desc += "; current model has no adjustable reasoning levels" | |
| 4524 | - | } else { | |
| 4525 | - | desc += "; only levels supported by the current model are shown" | |
| 4526 | - | } | |
| 4527 | - | } | |
| 4528 | - | options := reasoningSettingOptions(levels) | |
| 4529 | - | choice := 0 | |
| 4530 | - | for idx, opt := range options { | |
| 4531 | - | if opt.value == reasoning { | |
| 4532 | - | choice = idx | |
| 4533 | - | break | |
| 4534 | - | } | |
| 4535 | - | } | |
| 4536 | - | ||
| 4537 | - | return settingsItem{ | |
| 4538 | - | key: "reasoning", | |
| 4539 | - | label: "reasoning level", | |
| 4540 | - | desc: desc, | |
| 4541 | - | options: options, | |
| 4542 | - | choice: choice, | |
| 4543 | - | hint: hint, | |
| 4544 | - | } | |
| 4545 | - | } | |
| 4546 | - | ||
| 4547 | - | func (i *Interactive) openReasoningDialog() { | |
| 4548 | - | i.settingsDialog.OpenDirectOption(i.reasoningSettingItem()) | |
| 4549 | - | } | |
| 4550 | - | ||
| 4551 | - | func (i *Interactive) openSettingsDialog() { | |
| 4552 | - | detected := tui.DetectImageProtocol() | |
| 4553 | - | imgEnabled := detected != tui.ImageProtocolNone | |
| 4554 | - | if i.cfg.InlineImagesEnabled != nil { | |
| 4555 | - | imgEnabled = *i.cfg.InlineImagesEnabled | |
| 4556 | - | } | |
| 4557 | - | imgDisabled := detected == tui.ImageProtocolNone | |
| 4558 | - | imgHint := "" | |
| 4559 | - | if imgDisabled { | |
| 4560 | - | imgEnabled = false | |
| 4561 | - | imgHint = "this terminal does not support inline images" | |
| 4562 | - | } else { | |
| 4563 | - | imgHint = "terminal supports " + imageProtocolName(detected) | |
| 4564 | - | } | |
| 4565 | - | ||
| 4566 | - | terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) | |
| 4567 | - | terminalTitles := terminalTitleEnabled(i.cfg.TerminalTitleEnabled) | |
| 4568 | - | autoSubagents := false | |
| 4569 | - | if i.cfg.AutoSubagentsEnabled != nil { | |
| 4570 | - | autoSubagents = *i.cfg.AutoSubagentsEnabled | |
| 4571 | - | } | |
| 4572 | - | autoSubagentsDisabled := !i.autoSubagentsAvailable() | |
| 4573 | - | autoSubagentsHint := i.autoSubagentsUnavailableHint() | |
| 4574 | - | if autoSubagentsDisabled { | |
| 4575 | - | autoSubagents = false | |
| 4576 | - | } | |
| 4577 | - | ||
| 4578 | - | ponytailEnabled := i.ponytailEnabled() | |
| 4579 | - | ponytailDisabled := !i.ponytailSettingsAvailable() | |
| 4580 | - | ponytailHint := "" | |
| 4581 | - | if ponytailDisabled { | |
| 4582 | - | ponytailHint = "requires persistent settings and live prompt refresh support" | |
| 4583 | - | } | |
| 4584 | - | ||
| 4585 | - | webSearchEnabled := i.webSearchEffectiveEnabled() | |
| 4586 | - | webSearchDisabled := !i.webSearchSettingsAvailable() | |
| 4587 | - | webSearchHint := i.webSearchUnavailableHint() | |
| 4588 | - | ||
| 4589 | - | fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode | |
| 4590 | - | fastModeHint := "OpenAI service tier" | |
| 4591 | - | if !provider.SupportsFastMode(i.cfg.Provider) { | |
| 4592 | - | fastModeHint = "only supported for OpenAI providers" | |
| 4593 | - | } | |
| 4594 | - | lspEnabled := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled | |
| 4595 | - | subagentLSPEnabled := i.cfg.SubagentLSPEnabled == nil || *i.cfg.SubagentLSPEnabled | |
| 4596 | - | ||
| 4597 | - | jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault | |
| 4598 | - | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | |
| 4599 | - | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | |
| 4600 | - | compactMode := i.compactModeEnabled() | |
| 4601 | - | showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup | |
| 4602 | - | inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) | |
| 4603 | - | statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition) | |
| 4604 | - | workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition) | |
| 4605 | - | quickItems := i.quickModelSettingItems() | |
| 4606 | - | ||
| 4607 | - | autoCompactOptions := []settingsOption{ | |
| 4608 | - | {value: "0", label: "off", desc: "disable context-percentage triggers; payload-too-large recovery stays enabled"}, | |
| 4609 | - | {value: "70", label: "70%", desc: "compact earlier to keep more context headroom"}, | |
| 4610 | - | {value: "80", label: "80%", desc: "compact with moderate context headroom"}, | |
| 4611 | - | {value: "85", label: "85%", desc: "default balance between history and headroom"}, | |
| 4612 | - | {value: "90", label: "90%", desc: "retain more history before compacting"}, | |
| 4613 | - | {value: "95", label: "95%", desc: "retain most history before compacting"}, | |
| 4614 | - | } | |
| 4615 | - | autoCompactThreshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold) | |
| 4616 | - | autoCompactChoice := 0 | |
| 4617 | - | for idx, opt := range autoCompactOptions { | |
| 4618 | - | if opt.value == strconv.Itoa(autoCompactThreshold) { | |
| 4619 | - | autoCompactChoice = idx | |
| 4620 | - | break | |
| 4621 | - | } | |
| 4622 | - | } | |
| 4623 | - | ||
| 4624 | - | reasoningItem := i.reasoningSettingItem() | |
| 4625 | - | ||
| 4626 | - | themeName := i.cfg.ThemeName | |
| 4627 | - | if themeName == "" { | |
| 4628 | - | themeName = "auto" | |
| 4629 | - | } | |
| 4630 | - | if themeName != "auto" && !tui.ThemeExists(i.cfg.ZutHome, themeName) { | |
| 4631 | - | themeName = "auto" | |
| 4632 | - | i.cfg.ThemeName = "" | |
| 4633 | - | if i.cfg.SettingsStore != nil { | |
| 4634 | - | _ = i.cfg.SettingsStore.SetTheme("auto") | |
| 4635 | - | } | |
| 4636 | - | i.applyThemeNow("auto") | |
| 4637 | - | } | |
| 4638 | - | themeOptions := []settingsOption{} | |
| 4639 | - | themeChoice := 0 | |
| 4640 | - | availableThemes := tui.AvailableThemes(i.cfg.ZutHome) | |
| 4641 | - | if i.cfg.ExtensionThemes != nil { | |
| 4642 | - | availableThemes = append(availableThemes, i.cfg.ExtensionThemes()...) | |
| 4643 | - | } | |
| 4644 | - | for idx, opt := range availableThemes { | |
| 4645 | - | themeOptions = append(themeOptions, settingsOption{value: opt.Value, label: opt.Label, desc: opt.Description}) | |
| 4646 | - | if opt.Value == themeName { | |
| 4647 | - | themeChoice = idx | |
| 4648 | - | } | |
| 4649 | - | } | |
| 4650 | - | ||
| 4651 | - | inputStyleOptions := []settingsOption{ | |
| 4652 | - | {value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"}, | |
| 4653 | - | {value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"}, | |
| 4654 | - | {value: tui.InputStyleBlock, label: "block", desc: "render the input as a user-bubble-style block"}, | |
| 4655 | - | } | |
| 4656 | - | inputStyleChoice := 0 | |
| 4657 | - | for idx, opt := range inputStyleOptions { | |
| 4658 | - | if opt.value == inputStyle { | |
| 4659 | - | inputStyleChoice = idx | |
| 4660 | - | break | |
| 4661 | - | } | |
| 4662 | - | } | |
| 4663 | - | statusPositionOptions := []settingsOption{ | |
| 4664 | - | {value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"}, | |
| 4665 | - | {value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"}, | |
| 4666 | - | } | |
| 4667 | - | statusPositionChoice := 0 | |
| 4668 | - | for idx, opt := range statusPositionOptions { | |
| 4669 | - | if opt.value == statusPosition { | |
| 4670 | - | statusPositionChoice = idx | |
| 4671 | - | break | |
| 4672 | - | } | |
| 4673 | - | } | |
| 4674 | - | ||
| 4675 | - | workingPositionOptions := []settingsOption{ | |
| 4676 | - | {value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"}, | |
| 4677 | - | {value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"}, | |
| 4678 | - | } | |
| 4679 | - | workingPositionChoice := 0 | |
| 4680 | - | for idx, opt := range workingPositionOptions { | |
| 4681 | - | if opt.value == workingPosition { | |
| 4682 | - | workingPositionChoice = idx | |
| 4683 | - | break | |
| 4684 | - | } | |
| 4685 | - | } | |
| 4686 | - | ||
| 4687 | - | items := []settingsItem{ | |
| 4688 | - | { | |
| 4689 | - | key: "inline_images_enabled", | |
| 4690 | - | label: "render images when supported", | |
| 4691 | - | desc: "draw screenshots inline instead of showing a text placeholder", | |
| 4692 | - | value: imgEnabled, | |
| 4693 | - | disabled: imgDisabled, | |
| 4694 | - | hint: imgHint, | |
| 4695 | - | }, | |
| 4696 | - | { | |
| 4697 | - | key: "terminal_alerts_enabled", | |
| 4698 | - | label: "terminal alerts", | |
| 4699 | - | desc: "ring the terminal when the main session needs attention or an extension raises an alert", | |
| 4700 | - | value: terminalAlerts, | |
| 4701 | - | }, | |
| 4702 | - | { | |
| 4703 | - | key: "terminal_title_enabled", | |
| 4704 | - | label: "AI terminal titles", | |
| 4705 | - | desc: "ask the active model for a concise title after the first prompt; uses one extra hidden request", | |
| 4706 | - | value: terminalTitles, | |
| 4707 | - | }, | |
| 4708 | - | { | |
| 4709 | - | key: "auto_subagents_enabled", | |
| 4710 | - | label: "Subagent Orchestrator", | |
| 4711 | - | desc: "automatically delegate coding work to sub-agents; tools stay available when disabled", | |
| 4712 | - | value: autoSubagents, | |
| 4713 | - | disabled: autoSubagentsDisabled, | |
| 4714 | - | hint: autoSubagentsHint, | |
| 4715 | - | }, | |
| 4716 | - | { | |
| 4717 | - | key: "ponytail_enabled", | |
| 4718 | - | label: "ponytail coding mode", | |
| 4719 | - | desc: "apply compact coding guidance that favors small, validated, maintainable changes", | |
| 4720 | - | value: ponytailEnabled, | |
| 4721 | - | disabled: ponytailDisabled, | |
| 4722 | - | hint: ponytailHint, | |
| 4723 | - | }, | |
| 4724 | - | { | |
| 4725 | - | key: "web_search_enabled", | |
| 4726 | - | label: "web search", | |
| 4727 | - | desc: "search DuckDuckGo for bounded public-web sources; sends queries to the public backend", | |
| 4728 | - | value: webSearchEnabled, | |
| 4729 | - | disabled: webSearchDisabled, | |
| 4730 | - | hint: webSearchHint, | |
| 4731 | - | }, | |
| 4732 | - | { | |
| 4733 | - | key: "fast_mode", | |
| 4734 | - | label: "fast mode", | |
| 4735 | - | desc: "request the provider's fast tier where supported; unsupported providers return an error", | |
| 4736 | - | value: fastMode, | |
| 4737 | - | hint: fastModeHint, | |
| 4738 | - | }, | |
| 4739 | - | { | |
| 4740 | - | key: "lsp_enabled", | |
| 4741 | - | label: "lsp in main session", | |
| 4742 | - | desc: "enable the lsp tool and code diagnostics for the main agent", | |
| 4743 | - | value: lspEnabled, | |
| 4744 | - | }, | |
| 4745 | - | { | |
| 4746 | - | key: "subagent_lsp_enabled", | |
| 4747 | - | label: "lsp in sub-agents", | |
| 4748 | - | desc: "allow newly spawned background sub-agents to use the lsp tool", | |
| 4749 | - | value: subagentLSPEnabled, | |
| 4750 | - | }, | |
| 4751 | - | { | |
| 4752 | - | key: "auto_compact_threshold", | |
| 4753 | - | label: "auto-compact threshold", | |
| 4754 | - | desc: "choose how full the model context can get before zut condenses conversation history", | |
| 4755 | - | options: autoCompactOptions, | |
| 4756 | - | choice: autoCompactChoice, | |
| 4757 | - | }, | |
| 4758 | - | { | |
| 4759 | - | key: "jail_by_default", | |
| 4760 | - | label: "jail new sessions by default", | |
| 4761 | - | desc: "confine tools to the session working directory unless /unjail is used", | |
| 4762 | - | value: jailByDefault, | |
| 4763 | - | }, | |
| 4764 | - | { | |
| 4765 | - | key: "recursive_file_suggest", | |
| 4766 | - | label: "recursive @-file search", | |
| 4767 | - | desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time", | |
| 4768 | - | value: recursiveFiles, | |
| 4769 | - | }, | |
| 4770 | - | { | |
| 4771 | - | key: "respect_gitignore", | |
| 4772 | - | label: "hide gitignored files in @-picker", | |
| 4773 | - | desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @", | |
| 4774 | - | value: respectGitignore, | |
| 4775 | - | }, | |
| 4776 | - | { | |
| 4777 | - | key: "compact_mode", | |
| 4778 | - | label: "compact transcript rendering", | |
| 4779 | - | desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles", | |
| 4780 | - | value: compactMode, | |
| 4781 | - | }, | |
| 4782 | - | { | |
| 4783 | - | key: "show_instructions_at_startup", | |
| 4784 | - | label: "show loaded resources at startup", | |
| 4785 | - | desc: "list loaded context files, extensions, and user-installed skills above the transcript", | |
| 4786 | - | value: showInstructions, | |
| 4787 | - | }, | |
| 4788 | - | { | |
| 4789 | - | key: "tui_settings", | |
| 4790 | - | label: "tui settings", | |
| 4791 | - | desc: "choose input chrome in its order around the prompt", | |
| 4792 | - | children: []settingsItem{ | |
| 4793 | - | { | |
| 4794 | - | key: "tui_status_position", | |
| 4795 | - | label: "status position", | |
| 4796 | - | desc: "place model, usage, and working directory above or below the input", | |
| 4797 | - | options: statusPositionOptions, | |
| 4798 | - | choice: statusPositionChoice, | |
| 4799 | - | }, | |
| 4800 | - | { | |
| 4801 | - | key: "tui_working_position", | |
| 4802 | - | label: "working spinner position", | |
| 4803 | - | desc: "place the working spinner above or below the input", | |
| 4804 | - | options: workingPositionOptions, | |
| 4805 | - | choice: workingPositionChoice, | |
| 4806 | - | }, | |
| 4807 | - | { | |
| 4808 | - | key: "tui_input_style", | |
| 4809 | - | label: "input style", | |
| 4810 | - | desc: "choose between the plain prompt, lines, and a block input area", | |
| 4811 | - | options: inputStyleOptions, | |
| 4812 | - | choice: inputStyleChoice, | |
| 4813 | - | }, | |
| 4814 | - | }, | |
| 4815 | - | }, | |
| 4816 | - | reasoningItem, | |
| 4817 | - | { | |
| 4818 | - | key: "theme", | |
| 4819 | - | label: "color theme", | |
| 4820 | - | desc: "choose auto, inherited terminal colors, a built-in dark/light theme, or a loaded theme file", | |
| 4821 | - | options: themeOptions, | |
| 4822 | - | choice: themeChoice, | |
| 4823 | - | }, | |
| 4824 | - | } | |
| 4825 | - | if len(quickItems) > 0 { | |
| 4826 | - | items = append(items, settingsItem{ | |
| 4827 | - | key: "quick_models", | |
| 4828 | - | label: "model shortcuts", | |
| 4829 | - | desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches", | |
| 4830 | - | children: quickItems, | |
| 4831 | - | }) | |
| 4832 | - | } | |
| 4833 | - | i.settingsDialog.Open(items) | |
| 4834 | - | } | |
| 4835 | - | ||
| 4836 | - | func (i *Interactive) applySettingChange(act settingsAction) { | |
| 4837 | - | switch { | |
| 4838 | - | case strings.HasPrefix(act.Key, "quick_model_"): | |
| 4839 | - | i.applyQuickModelSetting(act.Key, act.StringValue) | |
| 4840 | - | case act.Key == "reasoning": | |
| 4841 | - | i.applyReasoningSetting(act.StringValue) | |
| 4842 | - | case act.Key == "theme": | |
| 4843 | - | i.applyThemeSetting(act.StringValue) | |
| 4844 | - | case act.Key == "auto_compact_threshold": | |
| 4845 | - | i.applyAutoCompactThresholdSetting(act.StringValue) | |
| 4846 | - | case act.Key == "tui_input_style": | |
| 4847 | - | i.applyTUIInputStyleSetting(act.StringValue) | |
| 4848 | - | case act.Key == "tui_status_position": | |
| 4849 | - | i.applyTUIStatusPositionSetting(act.StringValue) | |
| 4850 | - | case act.Key == "tui_working_position": | |
| 4851 | - | i.applyTUIWorkingPositionSetting(act.StringValue) | |
| 4852 | - | default: | |
| 4853 | - | i.applySettingToggle(act.Key, act.Value) | |
| 4854 | - | } | |
| 4855 | - | } | |
| 4856 | - | ||
| 4857 | - | func (i *Interactive) applyAutoCompactThresholdSetting(value string) { | |
| 4858 | - | threshold, err := strconv.Atoi(value) | |
| 4859 | - | if err != nil { | |
| 4860 | - | return | |
| 4861 | - | } | |
| 4862 | - | threshold = NormalizeAutoCompactThreshold(&threshold) | |
| 4863 | - | if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok { | |
| 4864 | - | if err := store.SetAutoCompactThreshold(threshold); err != nil { | |
| 4865 | - | i.mu.Lock() | |
| 4866 | - | i.statusErr = "settings: " + err.Error() | |
| 4867 | - | i.mu.Unlock() | |
| 4868 | - | return | |
| 4869 | - | } | |
| 4870 | - | } | |
| 4871 | - | i.mu.Lock() | |
| 4872 | - | i.cfg.AutoCompactThreshold = &threshold | |
| 4873 | - | if threshold == 0 { | |
| 4874 | - | i.statusOK = "auto-compact threshold off" | |
| 4875 | - | } else { | |
| 4876 | - | i.statusOK = fmt.Sprintf("auto-compact threshold %d%%", threshold) | |
| 4877 | - | } | |
| 4878 | - | i.statusErr = "" | |
| 4879 | - | i.mu.Unlock() | |
| 4880 | - | i.invalidate() | |
| 4881 | - | } | |
| 4882 | - | ||
| 4883 | - | func (i *Interactive) quickModelSettingItems() []settingsItem { | |
| 4884 | - | if len(i.cfg.QuickModelShortcuts) < 9 { | |
| 4885 | - | next := make([]QuickModelShortcut, 9) | |
| 4886 | - | copy(next, i.cfg.QuickModelShortcuts) | |
| 4887 | - | i.cfg.QuickModelShortcuts = next | |
| 4888 | - | } | |
| 4889 | - | items := make([]settingsItem, 0, 9) | |
| 4890 | - | for slot := 1; slot <= 9; slot++ { | |
| 4891 | - | items = append(items, i.quickModelSettingItem(slot)) | |
| 4892 | - | } | |
| 4893 | - | return items | |
| 4894 | - | } | |
| 4895 | - | ||
| 4896 | - | func (i *Interactive) quickModelSettingItem(slot int) settingsItem { | |
| 4897 | - | current := QuickModelShortcut{} | |
| 4898 | - | if slot >= 1 && len(i.cfg.QuickModelShortcuts) >= slot { | |
| 4899 | - | current = i.cfg.QuickModelShortcuts[slot-1] | |
| 4900 | - | } | |
| 4901 | - | hint := "not assigned" | |
| 4902 | - | if current.Provider != "" && current.Model != "" { | |
| 4903 | - | hint = current.Provider + " / " + current.Model | |
| 4904 | - | } | |
| 4905 | - | return settingsItem{ | |
| 4906 | - | key: "quick_model_" + strconv.Itoa(slot), | |
| 4907 | - | label: "model " + strconv.Itoa(slot), | |
| 4908 | - | desc: quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.", | |
| 4909 | - | picker: true, | |
| 4910 | - | hint: hint, | |
| 4911 | - | } | |
| 4912 | - | } | |
| 4913 | - | ||
| 4914 | - | func (i *Interactive) compactModeEnabled() bool { | |
| 4915 | - | return i.cfg.CompactMode != nil && *i.cfg.CompactMode | |
| 4916 | - | } | |
| 4917 | - | ||
| 4918 | - | func quickModelShortcutSlot(k tui.Key) int { | |
| 4919 | - | if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' { | |
| 4920 | - | return 0 | |
| 4921 | - | } | |
| 4922 | - | if runtime.GOOS == "darwin" { | |
| 4923 | - | if !k.Super && !k.Ctrl { | |
| 4924 | - | return 0 | |
| 4925 | - | } | |
| 4926 | - | } else if !k.Ctrl { | |
| 4927 | - | return 0 | |
| 4928 | - | } | |
| 4929 | - | return int(k.Rune - '0') | |
| 4930 | - | } | |
| 4931 | - | ||
| 4932 | - | func quickModelShortcutPrefix() string { | |
| 4933 | - | return "Ctrl" | |
| 4934 | - | } | |
| 4935 | - | ||
| 4936 | - | func quickModelShortcutLabel(slot int) string { | |
| 4937 | - | return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot) | |
| 4938 | - | } | |
| 4939 | - | ||
| 4940 | - | func (i *Interactive) openQuickModelPicker(slot int) { | |
| 4941 | - | if slot < 1 || slot > 9 { | |
| 4942 | - | return | |
| 4943 | - | } | |
| 4944 | - | i.quickModelAssign = slot | |
| 4945 | - | current := i.cfg.Model | |
| 4946 | - | if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" { | |
| 4947 | - | current = i.cfg.QuickModelShortcuts[slot-1].Model | |
| 4948 | - | } | |
| 4949 | - | var loggedIn []string | |
| 4950 | - | if i.cfg.LoggedInProviders != nil { | |
| 4951 | - | loggedIn = i.cfg.LoggedInProviders() | |
| 4952 | - | } | |
| 4953 | - | i.modelDialog.Open(current, loggedIn, i.cfg.Reasoning) | |
| 4954 | - | } | |
| 4955 | - | ||
| 4956 | - | func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) { | |
| 4957 | - | i.setQuickModelShortcut(slot, providerName, model) | |
| 4958 | - | } | |
| 4959 | - | ||
| 4960 | - | func (i *Interactive) applyQuickModelShortcut(slot int) { | |
| 4961 | - | if slot < 1 || slot > 9 { | |
| 4962 | - | return | |
| 4963 | - | } | |
| 4964 | - | if i.busy { | |
| 4965 | - | i.mu.Lock() | |
| 4966 | - | i.statusErr = "cannot switch model while a turn is running" | |
| 4967 | - | i.statusOK = "" | |
| 4968 | - | i.mu.Unlock() | |
| 4969 | - | i.invalidate() | |
| 4970 | - | return | |
| 4971 | - | } | |
| 4972 | - | if len(i.cfg.QuickModelShortcuts) < slot { | |
| 4973 | - | i.mu.Lock() | |
| 4974 | - | i.statusErr = quickModelShortcutLabel(slot) + " is not assigned" | |
| 4975 | - | i.statusOK = "" | |
| 4976 | - | i.mu.Unlock() | |
| 4977 | - | i.invalidate() | |
| 4978 | - | return | |
| 4979 | - | } | |
| 4980 | - | shortcut := i.cfg.QuickModelShortcuts[slot-1] | |
| 4981 | - | if shortcut.Provider == "" || shortcut.Model == "" { | |
| 4982 | - | i.mu.Lock() | |
| 4983 | - | i.statusErr = quickModelShortcutLabel(slot) + " is not assigned" | |
| 4984 | - | i.statusOK = "" | |
| 4985 | - | i.mu.Unlock() | |
| 4986 | - | i.invalidate() | |
| 4987 | - | return | |
| 4988 | - | } | |
| 4989 | - | i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false) | |
| 4990 | - | i.invalidate() | |
| 4991 | - | } | |
| 4992 | - | ||
| 4993 | - | func (i *Interactive) applyQuickModelSetting(key, value string) { | |
| 4994 | - | slotText := strings.TrimPrefix(key, "quick_model_") | |
| 4995 | - | slot, err := strconv.Atoi(slotText) | |
| 4996 | - | if err != nil || slot < 1 || slot > 9 { | |
| 4997 | - | return | |
| 4998 | - | } | |
| 4999 | - | providerName, model := "", "" | |
| 5000 | - | if value != "" { | |
| 5001 | - | parts := strings.SplitN(value, "\t", 2) | |
| 5002 | - | if len(parts) == 2 { | |
| 5003 | - | providerName, model = parts[0], parts[1] | |
| 5004 | - | } | |
| 5005 | - | } | |
| 5006 | - | i.setQuickModelShortcut(slot, providerName, model) | |
| 5007 | - | } | |
| 5008 | - | ||
| 5009 | - | func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) { | |
| 5010 | - | if len(i.cfg.QuickModelShortcuts) < slot { | |
| 5011 | - | next := make([]QuickModelShortcut, slot) | |
| 5012 | - | copy(next, i.cfg.QuickModelShortcuts) | |
| 5013 | - | i.cfg.QuickModelShortcuts = next | |
| 5014 | - | } | |
| 5015 | - | i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model} | |
| 5016 | - | if i.cfg.SettingsStore != nil { | |
| 5017 | - | if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil { | |
| 5018 | - | i.mu.Lock() | |
| 5019 | - | i.statusErr = "settings: " + err.Error() | |
| 5020 | - | i.mu.Unlock() | |
| 5021 | - | return | |
| 5022 | - | } | |
| 5023 | - | } | |
| 5024 | - | i.mu.Lock() | |
| 5025 | - | if model == "" { | |
| 5026 | - | i.statusOK = quickModelShortcutLabel(slot) + " cleared" | |
| 5027 | - | } else { | |
| 5028 | - | i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model | |
| 5029 | - | } | |
| 5030 | - | i.statusErr = "" | |
| 5031 | - | i.mu.Unlock() | |
| 5032 | - | i.refreshQuickModelSettingsItem(slot) | |
| 5033 | - | i.invalidate() | |
| 5034 | - | } | |
| 5035 | - | ||
| 5036 | - | func (i *Interactive) refreshQuickModelSettingsItem(slot int) { | |
| 5037 | - | if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 { | |
| 5038 | - | return | |
| 5039 | - | } | |
| 5040 | - | key := "quick_model_" + strconv.Itoa(slot) | |
| 5041 | - | for idx, it := range i.settingsDialog.items { | |
| 5042 | - | if it.key == key { | |
| 5043 | - | i.settingsDialog.items[idx] = i.quickModelSettingItem(slot) | |
| 5044 | - | return | |
| 5045 | - | } | |
| 5046 | - | } | |
| 5047 | - | } | |
| 5048 | - | ||
| 5049 | - | func (i *Interactive) resetSettingsToggle(key string, value bool) { | |
| 5050 | - | if i.settingsDialog == nil { | |
| 5051 | - | return | |
| 5052 | - | } | |
| 5053 | - | for idx := range i.settingsDialog.items { | |
| 5054 | - | if i.settingsDialog.items[idx].key == key { | |
| 5055 | - | i.settingsDialog.items[idx].value = value | |
| 5056 | - | return | |
| 5057 | - | } | |
| 5058 | - | } | |
| 5059 | - | } | |
| 5060 | - | ||
| 5061 | - | func (i *Interactive) ponytailEnabled() bool { | |
| 5062 | - | return i.cfg.PonytailEnabled == nil || *i.cfg.PonytailEnabled | |
| 5063 | - | } | |
| 5064 | - | ||
| 5065 | - | func (i *Interactive) ponytailSettingsAvailable() bool { | |
| 5066 | - | _, persistent := i.cfg.SettingsStore.(ponytailSettingsStore) | |
| 5067 | - | return persistent && i.cfg.RefreshPrompt != nil | |
| 5068 | - | } | |
| 5069 | - | ||
| 5070 | - | func (i *Interactive) webSearchEnabled() bool { | |
| 5071 | - | return i.cfg.WebSearchEnabled == nil || *i.cfg.WebSearchEnabled | |
| 5072 | - | } | |
| 5073 | - | ||
| 5074 | - | func (i *Interactive) webSearchToolAllowed() bool { | |
| 5075 | - | return i.cfg.WebSearchToolAllowed == nil || *i.cfg.WebSearchToolAllowed | |
| 5076 | - | } | |
| 5077 | - | ||
| 5078 | - | func (i *Interactive) webSearchEffectiveEnabled() bool { | |
| 5079 | - | return i.webSearchToolAllowed() && (i.cfg.WebSearchInvocationOverride || i.webSearchEnabled()) | |
| 5080 | - | } | |
| 5081 | - | ||
| 5082 | - | func (i *Interactive) webSearchSettingsAvailable() bool { | |
| 5083 | - | _, persistent := i.cfg.SettingsStore.(webSearchSettingsStore) | |
| 5084 | - | return i.webSearchToolAllowed() && !i.cfg.WebSearchInvocationOverride && persistent && i.cfg.RefreshTools != nil | |
| 5085 | - | } | |
| 5086 | - | ||
| 5087 | - | func (i *Interactive) webSearchUnavailableHint() string { | |
| 5088 | - | if !i.webSearchToolAllowed() { | |
| 5089 | - | return "unavailable in this session due to the launch-time tool capability ceiling" | |
| 5090 | - | } | |
| 5091 | - | if i.cfg.WebSearchInvocationOverride { | |
| 5092 | - | return "enabled for this session by explicit --tools web_search; the persisted setting applies to invocations without --tools" | |
| 5093 | - | } | |
| 5094 | - | if !i.webSearchSettingsAvailable() { | |
| 5095 | - | return "requires persistent settings and live tool refresh support" | |
| 5096 | - | } | |
| 5097 | - | return "" | |
| 5098 | - | } | |
| 5099 | - | ||
| 5100 | - | func (i *Interactive) setWebSearchAvailable(available bool) { | |
| 5101 | - | // Advance before publishing the new gate/child ceiling. A resolver that | |
| 5102 | - | // captured the prior generation can no longer commit after this transition. | |
| 5103 | - | // Callers may hold agentMu and i.mu; the callback must complete without | |
| 5104 | - | // calling back into Interactive or requiring either lock. | |
| 5105 | - | i.webSearchPolicyGeneration.Add(1) | |
| 5106 | - | if i.cfg.SetWebSearchAvailable != nil { | |
| 5107 | - | i.cfg.SetWebSearchAvailable(available) | |
| 5108 | - | } | |
| 5109 | - | } | |
| 5110 | - | ||
| 5111 | - | func (i *Interactive) applyWebSearchSettingToggle(key string, value bool) { | |
| 5112 | - | previous := i.webSearchEnabled() | |
| 5113 | - | store, persistent := i.cfg.SettingsStore.(webSearchSettingsStore) | |
| 5114 | - | if !i.webSearchSettingsAvailable() || !persistent { | |
| 5115 | - | i.resetSettingsToggle(key, i.webSearchEffectiveEnabled()) | |
| 5116 | - | i.mu.Lock() | |
| 5117 | - | i.statusOK = "" | |
| 5118 | - | i.statusErr = "web search unavailable: " + i.webSearchUnavailableHint() | |
| 5119 | - | i.mu.Unlock() | |
| 5120 | - | return | |
| 5121 | - | } | |
| 5122 | - | if err := store.SetWebSearchEnabled(value); err != nil { | |
| 5123 | - | i.resetSettingsToggle(key, previous) | |
| 5124 | - | i.mu.Lock() | |
| 5125 | - | i.statusOK = "" | |
| 5126 | - | i.statusErr = "settings: " + err.Error() | |
| 5127 | - | i.mu.Unlock() | |
| 5128 | - | return | |
| 5129 | - | } | |
| 5130 | - | val := value | |
| 5131 | - | i.cfg.WebSearchEnabled = &val | |
| 5132 | - | // Revoke and advance the policy generation for both directions. Enabling | |
| 5133 | - | // remains fail-closed until the freshly resolved registry commits, while a | |
| 5134 | - | // stale resolve from before this persisted transition is rejected. | |
| 5135 | - | i.stripWebSearchTool() | |
| 5136 | - | if err := i.cfg.RefreshTools(); err != nil { | |
| 5137 | - | // Refresh failure leaves the old registry's state unknown. Revoke both | |
| 5138 | - | // live execution and generic-child inheritance before rollback. | |
| 5139 | - | i.stripWebSearchTool() | |
| 5140 | - | errMsg := "settings: web search tool refresh: " + err.Error() | |
| 5141 | - | if rollbackErr := store.SetWebSearchEnabled(previous); rollbackErr != nil { | |
| 5142 | - | failClosed := false | |
| 5143 | - | i.cfg.WebSearchEnabled = &failClosed | |
| 5144 | - | i.resetSettingsToggle(key, false) | |
| 5145 | - | errMsg += "; rollback persistence: " + rollbackErr.Error() | |
| 5146 | - | } else { | |
| 5147 | - | previousVal := previous | |
| 5148 | - | i.cfg.WebSearchEnabled = &previousVal | |
| 5149 | - | i.resetSettingsToggle(key, previous) | |
| 5150 | - | if refreshErr := i.cfg.RefreshTools(); refreshErr != nil { | |
| 5151 | - | errMsg += "; rollback refresh: " + refreshErr.Error() | |
| 5152 | - | i.stripWebSearchTool() | |
| 5153 | - | } | |
| 5154 | - | } | |
| 5155 | - | i.mu.Lock() | |
| 5156 | - | i.statusOK = "" | |
| 5157 | - | i.statusErr = errMsg | |
| 5158 | - | i.mu.Unlock() | |
| 5159 | - | return | |
| 5160 | - | } | |
| 5161 | - | i.mu.Lock() | |
| 5162 | - | i.statusOK = "web search " + onOff(value) | |
| 5163 | - | i.statusErr = "" | |
| 5164 | - | i.mu.Unlock() | |
| 5165 | - | } | |
| 5166 | - | ||
| 5167 | - | // stripWebSearchTool removes the callable web_search entry from the live | |
| 5168 | - | // registry without asking the resolver to run again. It is the fail-closed | |
| 5169 | - | // fallback when persistence rollback fails after a refresh failure. | |
| 5170 | - | func (i *Interactive) stripWebSearchTool() { | |
| 5171 | - | // Revoke first so a stale tool snapshotted by core cannot execute while | |
| 5172 | - | // the advertised registry is being replaced. | |
| 5173 | - | i.setWebSearchAvailable(false) | |
| 5174 | - | i.agentMu.Lock() | |
| 5175 | - | defer i.agentMu.Unlock() | |
| 5176 | - | i.mu.Lock() | |
| 5177 | - | defer i.mu.Unlock() | |
| 5178 | - | if i.agent == nil { | |
| 5179 | - | return | |
| 5180 | - | } | |
| 5181 | - | tools := i.agent.ToolsSnapshot() | |
| 5182 | - | if _, ok := tools["web_search"]; !ok { | |
| 5183 | - | return | |
| 5184 | - | } | |
| 5185 | - | delete(tools, "web_search") | |
| 5186 | - | i.agent.SetTools(tools) | |
| 5187 | - | } | |
| 5188 | - | ||
| 5189 | - | func (i *Interactive) applySettingToggle(key string, value bool) { | |
| 5190 | - | // Every setting toggle forces a full repaint at the end — same | |
| 5191 | - | // effect as the user pressing Ctrl+L — so any per-setting visual | |
| 5192 | - | // change (image rendering, status copy, future toggles) lands | |
| 5193 | - | // immediately instead of waiting for the next diff frame. | |
| 5194 | - | defer func() { | |
| 5195 | - | i.requestRendererClear() | |
| 5196 | - | i.invalidate() | |
| 5197 | - | }() | |
| 5198 | - | switch key { | |
| 5199 | - | case "inline_images_enabled": | |
| 5200 | - | val := value | |
| 5201 | - | i.cfg.InlineImagesEnabled = &val | |
| 5202 | - | if i.cfg.SettingsStore != nil { | |
| 5203 | - | if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil { | |
| 5204 | - | i.mu.Lock() | |
| 5205 | - | i.statusErr = "settings: " + err.Error() | |
| 5206 | - | i.mu.Unlock() | |
| 5207 | - | return | |
| 5208 | - | } | |
| 5209 | - | } | |
| 5210 | - | i.mu.Lock() | |
| 5211 | - | i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled) | |
| 5212 | - | i.view.InvalidateRenderCache() | |
| 5213 | - | i.statusOK = "inline image rendering " + onOff(value) | |
| 5214 | - | i.statusErr = "" | |
| 5215 | - | i.mu.Unlock() | |
| 5216 | - | case "terminal_alerts_enabled": | |
| 5217 | - | val := value | |
| 5218 | - | i.mu.Lock() | |
| 5219 | - | i.cfg.TerminalAlertsEnabled = &val | |
| 5220 | - | i.mu.Unlock() | |
| 5221 | - | if store, ok := i.cfg.SettingsStore.(terminalAlertsSettingsStore); ok { | |
| 5222 | - | if err := store.SetTerminalAlertsEnabled(value); err != nil { | |
| 5223 | - | i.mu.Lock() | |
| 5224 | - | i.statusErr = "settings: " + err.Error() | |
| 5225 | - | i.mu.Unlock() | |
| 5226 | - | return | |
| 5227 | - | } | |
| 5228 | - | } | |
| 5229 | - | i.mu.Lock() | |
| 5230 | - | i.statusOK = "terminal alerts " + onOff(value) | |
| 5231 | - | i.statusErr = "" | |
| 5232 | - | i.mu.Unlock() | |
| 5233 | - | case "terminal_title_enabled": | |
| 5234 | - | val := value | |
| 5235 | - | var cancel context.CancelFunc | |
| 5236 | - | i.mu.Lock() | |
| 5237 | - | i.cfg.TerminalTitleEnabled = &val | |
| 5238 | - | if value { | |
| 5239 | - | i.writeTerminalTitleLocked(i.sessionTitle) | |
| 5240 | - | } else { | |
| 5241 | - | cancel = i.titleCancel | |
| 5242 | - | i.titleCancel = nil | |
| 5243 | - | i.titleVersion++ | |
| 5244 | - | i.writeTerminalTitleLocked("") | |
| 5245 | - | } | |
| 5246 | - | i.mu.Unlock() | |
| 5247 | - | if cancel != nil { | |
| 5248 | - | cancel() | |
| 5249 | - | } | |
| 5250 | - | if store, ok := i.cfg.SettingsStore.(terminalTitleSettingsStore); ok { | |
| 5251 | - | if err := store.SetTerminalTitleEnabled(value); err != nil { | |
| 5252 | - | i.mu.Lock() | |
| 5253 | - | i.statusErr = "settings: " + err.Error() | |
| 5254 | - | i.mu.Unlock() | |
| 5255 | - | return | |
| 5256 | - | } | |
| 5257 | - | } | |
| 5258 | - | i.mu.Lock() | |
| 5259 | - | i.statusOK = "AI terminal titles " + onOff(value) | |
| 5260 | - | i.statusErr = "" | |
| 5261 | - | i.mu.Unlock() | |
| 5262 | - | case "web_search_enabled": | |
| 5263 | - | apply := func() { i.applyWebSearchSettingToggle(key, value) } | |
| 5264 | - | if i.cfg.SessionTransition != nil { | |
| 5265 | - | i.cfg.SessionTransition(apply) | |
| 5266 | - | } else { | |
| 5267 | - | apply() | |
| 5268 | - | } | |
| 5269 | - | case "ponytail_enabled": | |
| 5270 | - | previous := i.ponytailEnabled() | |
| 5271 | - | store, available := i.cfg.SettingsStore.(ponytailSettingsStore) | |
| 5272 | - | if !available || i.cfg.RefreshPrompt == nil { | |
| 5273 | - | i.resetSettingsToggle(key, previous) | |
| 5274 | - | i.mu.Lock() | |
| 5275 | - | i.statusOK = "" | |
| 5276 | - | i.statusErr = "ponytail coding mode unavailable: persistent settings and live prompt refresh are required" | |
| 5277 | - | i.mu.Unlock() | |
| 5278 | - | return | |
| 5279 | - | } | |
| 5280 | - | if err := store.SetPonytailEnabled(value); err != nil { | |
| 5281 | - | i.resetSettingsToggle(key, previous) | |
| 5282 | - | i.mu.Lock() | |
| 5283 | - | i.statusOK = "" | |
| 5284 | - | i.statusErr = "settings: " + err.Error() | |
| 5285 | - | i.mu.Unlock() | |
| 5286 | - | return | |
| 5287 | - | } | |
| 5288 | - | val := value | |
| 5289 | - | i.cfg.PonytailEnabled = &val | |
| 5290 | - | if err := i.cfg.RefreshPrompt(); err != nil { | |
| 5291 | - | errMsg := "settings: ponytail prompt refresh: " + err.Error() | |
| 5292 | - | if rollbackErr := store.SetPonytailEnabled(previous); rollbackErr != nil { | |
| 5293 | - | // The first write succeeded, so keep the in-memory setting at | |
| 5294 | - | // the value that remains durable instead of claiming that the | |
| 5295 | - | // rollback took effect. | |
| 5296 | - | i.resetSettingsToggle(key, value) | |
| 5297 | - | errMsg += "; rollback persistence: " + rollbackErr.Error() | |
| 5298 | - | if reconcileErr := i.cfg.RefreshPrompt(); reconcileErr != nil { | |
| 5299 | - | errMsg += "; durable-state refresh: " + reconcileErr.Error() | |
| 5300 | - | } | |
| 5301 | - | } else { | |
| 5302 | - | previousVal := previous | |
| 5303 | - | i.cfg.PonytailEnabled = &previousVal | |
| 5304 | - | i.resetSettingsToggle(key, previous) | |
| 5305 | - | if refreshErr := i.cfg.RefreshPrompt(); refreshErr != nil { | |
| 5306 | - | errMsg += "; rollback refresh: " + refreshErr.Error() | |
| 5307 | - | } | |
| 5308 | - | } | |
| 5309 | - | i.mu.Lock() | |
| 5310 | - | i.statusOK = "" | |
| 5311 | - | i.statusErr = errMsg | |
| 5312 | - | i.mu.Unlock() | |
| 5313 | - | return | |
| 5314 | - | } | |
| 5315 | - | i.mu.Lock() | |
| 5316 | - | i.statusOK = "ponytail coding mode " + onOff(value) | |
| 5317 | - | i.statusErr = "" | |
| 5318 | - | i.mu.Unlock() | |
| 5319 | - | case "auto_subagents_enabled": | |
| 5320 | - | if value && !i.autoSubagentsAvailable() { | |
| 5321 | - | i.mu.Lock() | |
| 5322 | - | i.statusOK = "" | |
| 5323 | - | i.statusErr = "subagent orchestrator unavailable: " + i.autoSubagentsUnavailableHint() | |
| 5324 | - | i.mu.Unlock() | |
| 5325 | - | return | |
| 5326 | - | } | |
| 5327 | - | i.mu.Lock() | |
| 5328 | - | previousFlag := i.cfg.AutoSubagentsEnabled | |
| 5329 | - | previous := previousFlag != nil && *previousFlag | |
| 5330 | - | val := value | |
| 5331 | - | i.cfg.AutoSubagentsEnabled = &val | |
| 5332 | - | i.mu.Unlock() | |
| 5333 | - | if i.cfg.SettingsStore != nil { | |
| 5334 | - | if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil { | |
| 5335 | - | i.mu.Lock() | |
| 5336 | - | i.cfg.AutoSubagentsEnabled = previousFlag | |
| 5337 | - | i.statusOK = "" | |
| 5338 | - | i.statusErr = "settings: " + err.Error() | |
| 5339 | - | i.mu.Unlock() | |
| 5340 | - | i.resetSettingsToggle(key, previous) | |
| 5341 | - | return | |
| 5342 | - | } | |
| 5343 | - | } | |
| 5344 | - | // The subagent tools remain available for explicit user requests. | |
| 5345 | - | // Swap only the system-prompt guidance between strict orchestration and | |
| 5346 | - | // on-demand delegation. | |
| 5347 | - | i.applyAutoSubagentsTool() | |
| 5348 | - | // Also swap the system-prompt addendum so the model knows whether it | |
| 5349 | - | // should orchestrate proactively or delegate only on user request. | |
| 5350 | - | i.applyAutoSubagentsSystemPrompt(value) | |
| 5351 | - | i.mu.Lock() | |
| 5352 | - | i.statusOK = "subagent orchestrator " + onOff(value) | |
| 5353 | - | i.statusErr = "" | |
| 5354 | - | i.mu.Unlock() | |
| 5355 | - | case "fast_mode": | |
| 5356 | - | previous := i.cfg.FastMode != nil && *i.cfg.FastMode | |
| 5357 | - | if err := provider.ValidateFastMode(i.cfg.Provider, value); err != nil { | |
| 5358 | - | i.resetSettingsToggle(key, previous) | |
| 5359 | - | i.mu.Lock() | |
| 5360 | - | i.statusErr = "settings: " + err.Error() | |
| 5361 | - | i.mu.Unlock() | |
| 5362 | - | return | |
| 5363 | - | } | |
| 5364 | - | if store, ok := i.cfg.SettingsStore.(fastModeSettingsStore); ok { | |
| 5365 | - | if err := store.SetFastMode(value); err != nil { | |
| 5366 | - | i.resetSettingsToggle(key, previous) | |
| 5367 | - | i.mu.Lock() | |
| 5368 | - | i.statusErr = "settings: " + err.Error() | |
| 5369 | - | i.mu.Unlock() | |
| 5370 | - | return | |
| 5371 | - | } | |
| 5372 | - | } | |
| 5373 | - | val := value | |
| 5374 | - | i.cfg.FastMode = &val | |
| 5375 | - | if i.agent != nil { | |
| 5376 | - | i.agent.SetFastMode(value) | |
| 5377 | - | } | |
| 5378 | - | i.mu.Lock() | |
| 5379 | - | i.statusOK = "fast mode " + onOff(value) | |
| 5380 | - | i.statusErr = "" | |
| 5381 | - | i.mu.Unlock() | |
| 5382 | - | case "lsp_enabled": | |
| 5383 | - | previous := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled | |
| 5384 | - | store, hasStore := i.cfg.SettingsStore.(lspSettingsStore) | |
| 5385 | - | if hasStore { | |
| 5386 | - | if err := store.SetLSPEnabled(value); err != nil { | |
| 5387 | - | i.mu.Lock() | |
| 5388 | - | i.statusErr = "settings: " + err.Error() | |
| 5389 | - | i.mu.Unlock() | |
| 5390 | - | return | |
| 5391 | - | } | |
| 5392 | - | } | |
| 5393 | - | val := value | |
| 5394 | - | i.cfg.LSPEnabled = &val | |
| 5395 | - | if i.cfg.RefreshTools != nil { | |
| 5396 | - | if err := i.cfg.RefreshTools(); err != nil { | |
| 5397 | - | errMsg := "settings: refresh tools: " + err.Error() | |
| 5398 | - | rollbackErr := error(nil) | |
| 5399 | - | if hasStore { | |
| 5400 | - | rollbackErr = store.SetLSPEnabled(previous) | |
| 5401 | - | } | |
| 5402 | - | if rollbackErr != nil { | |
| 5403 | - | // The first write succeeded, so keep the in-memory setting at | |
| 5404 | - | // the value that remains durable instead of claiming that the | |
| 5405 | - | // rollback took effect. | |
| 5406 | - | errMsg += "; rollback persistence: " + rollbackErr.Error() | |
| 5407 | - | if reconcileErr := i.cfg.RefreshTools(); reconcileErr != nil { | |
| 5408 | - | errMsg += "; durable-state refresh: " + reconcileErr.Error() | |
| 5409 | - | } | |
| 5410 | - | } else { | |
| 5411 | - | previousVal := previous | |
| 5412 | - | i.cfg.LSPEnabled = &previousVal | |
| 5413 | - | if refreshErr := i.cfg.RefreshTools(); refreshErr != nil { | |
| 5414 | - | errMsg += "; rollback refresh: " + refreshErr.Error() | |
| 5415 | - | } | |
| 5416 | - | } | |
| 5417 | - | i.mu.Lock() | |
| 5418 | - | i.statusOK = "" | |
| 5419 | - | i.statusErr = errMsg | |
| 5420 | - | i.mu.Unlock() | |
| 5421 | - | return | |
| 5422 | - | } | |
| 5423 | - | } | |
| 5424 | - | i.mu.Lock() | |
| 5425 | - | i.statusOK = "lsp in main session " + onOff(value) | |
| 5426 | - | i.statusErr = "" | |
| 5427 | - | i.mu.Unlock() | |
| 5428 | - | case "subagent_lsp_enabled": | |
| 5429 | - | if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok { | |
| 5430 | - | if err := store.SetSubagentLSPEnabled(value); err != nil { | |
| 5431 | - | i.mu.Lock() | |
| 5432 | - | i.statusErr = "settings: " + err.Error() | |
| 5433 | - | i.mu.Unlock() | |
| 5434 | - | return | |
| 5435 | - | } | |
| 5436 | - | } | |
| 5437 | - | val := value | |
| 5438 | - | i.cfg.SubagentLSPEnabled = &val | |
| 5439 | - | i.mu.Lock() | |
| 5440 | - | i.statusOK = "lsp in sub-agents " + onOff(value) | |
| 5441 | - | i.statusErr = "" | |
| 5442 | - | i.mu.Unlock() | |
| 5443 | - | case "jail_by_default": | |
| 5444 | - | val := value | |
| 5445 | - | i.cfg.JailByDefault = &val | |
| 5446 | - | if i.cfg.SettingsStore != nil { | |
| 5447 | - | if err := i.cfg.SettingsStore.SetJailByDefault(value); err != nil { | |
| 5448 | - | i.mu.Lock() | |
| 5449 | - | i.statusErr = "settings: " + err.Error() | |
| 5450 | - | i.mu.Unlock() | |
| 5451 | - | return | |
| 5452 | - | } | |
| 5453 | - | } | |
| 5454 | - | if i.cfg.Sandbox != nil { | |
| 5455 | - | if value { | |
| 5456 | - | i.cfg.Sandbox.Lock() | |
| 5457 | - | } else { | |
| 5458 | - | i.cfg.Sandbox.Unlock() | |
| 5459 | - | } | |
| 5460 | - | } | |
| 5461 | - | i.mu.Lock() | |
| 5462 | - | i.statusOK = "jail by default " + onOff(value) | |
| 5463 | - | i.statusErr = "" | |
| 5464 | - | i.mu.Unlock() | |
| 5465 | - | case "recursive_file_suggest": | |
| 5466 | - | val := value | |
| 5467 | - | i.cfg.RecursiveFileSuggest = &val | |
| 5468 | - | if i.cfg.SettingsStore != nil { | |
| 5469 | - | if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil { | |
| 5470 | - | i.mu.Lock() | |
| 5471 | - | i.statusErr = "settings: " + err.Error() | |
| 5472 | - | i.mu.Unlock() | |
| 5473 | - | return | |
| 5474 | - | } | |
| 5475 | - | } | |
| 5476 | - | // Flip the live picker so the next @ reflects the new mode | |
| 5477 | - | // without restarting zut. SetRecursive drops its cache. | |
| 5478 | - | i.fileSuggest.SetRecursive(value) | |
| 5479 | - | i.mu.Lock() | |
| 5480 | - | i.statusOK = "recursive @-file search " + onOff(value) | |
| 5481 | - | i.statusErr = "" | |
| 5482 | - | i.mu.Unlock() | |
| 5483 | - | case "respect_gitignore": | |
| 5484 | - | val := value | |
| 5485 | - | i.cfg.RespectGitignore = &val | |
| 5486 | - | if i.cfg.SettingsStore != nil { | |
| 5487 | - | if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil { | |
| 5488 | - | i.mu.Lock() | |
| 5489 | - | i.statusErr = "settings: " + err.Error() | |
| 5490 | - | i.mu.Unlock() | |
| 5491 | - | return | |
| 5492 | - | } | |
| 5493 | - | } | |
| 5494 | - | i.fileSuggest.SetRespectGitignore(value) | |
| 5495 | - | i.mu.Lock() | |
| 5496 | - | i.statusOK = "hide gitignored files in @-picker " + onOff(value) | |
| 5497 | - | i.statusErr = "" | |
| 5498 | - | i.mu.Unlock() | |
| 5499 | - | case "compact_mode": | |
| 5500 | - | val := value | |
| 5501 | - | i.cfg.CompactMode = &val | |
| 5502 | - | if i.cfg.SettingsStore != nil { | |
| 5503 | - | if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil { | |
| 5504 | - | i.mu.Lock() | |
| 5505 | - | i.statusErr = "settings: " + err.Error() | |
| 5506 | - | i.mu.Unlock() | |
| 5507 | - | return | |
| 5508 | - | } | |
| 5509 | - | } | |
| 5510 | - | i.mu.Lock() | |
| 5511 | - | i.view.CompactMode = value | |
| 5512 | - | i.view.InvalidateRenderCache() | |
| 5513 | - | i.statusOK = "compact transcript rendering " + onOff(value) | |
| 5514 | - | i.statusErr = "" | |
| 5515 | - | i.mu.Unlock() | |
| 5516 | - | case "show_instructions_at_startup": | |
| 5517 | - | val := value | |
| 5518 | - | i.cfg.ShowInstructionsAtStartup = &val | |
| 5519 | - | if store, ok := i.cfg.SettingsStore.(showInstructionsSettingsStore); ok { | |
| 5520 | - | if err := store.SetShowInstructionsAtStartup(value); err != nil { | |
| 5521 | - | i.mu.Lock() | |
| 5522 | - | i.statusErr = "settings: " + err.Error() | |
| 5523 | - | i.mu.Unlock() | |
| 5524 | - | return | |
| 5525 | - | } | |
| 5526 | - | } | |
| 5527 | - | i.mu.Lock() | |
| 5528 | - | i.view.StartupAgentName = "" | |
| 5529 | - | i.view.StartupContextPaths = nil | |
| 5530 | - | i.view.StartupExtensionNames = nil | |
| 5531 | - | i.view.StartupSkillNames = nil | |
| 5532 | - | i.view.InvalidateRenderCache() | |
| 5533 | - | if value { | |
| 5534 | - | i.view.StartupAgentName = i.cfg.StartupAgentName | |
| 5535 | - | i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...) | |
| 5536 | - | i.view.StartupExtensionNames = append(i.view.StartupExtensionNames, i.cfg.StartupExtensionNames...) | |
| 5537 | - | i.view.StartupSkillNames = append(i.view.StartupSkillNames, i.cfg.StartupSkillNames...) | |
| 5538 | - | } | |
| 5539 | - | i.statusOK = "show loaded resources at startup " + onOff(value) | |
| 5540 | - | i.statusErr = "" | |
| 5541 | - | i.mu.Unlock() | |
| 5542 | - | } | |
| 5543 | - | } | |
| 1411 | + | hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:" | |
| 1412 | + | autoCompactContinueMetaKey = "auto_compact_continue" | |
| 1413 | + | shellEscapeMetaKey = "shell_escape" | |
| 1414 | + | ) | |
| 5544 | 1415 | ||
| 5545 | - | func (i *Interactive) applyTUIInputStyleSetting(style string) { | |
| 5546 | - | defer func() { | |
| 5547 | - | i.requestRendererClear() | |
| 5548 | - | i.invalidate() | |
| 5549 | - | }() | |
| 5550 | - | style = tui.NormalizeInputStyle(style) | |
| 5551 | - | i.cfg.TUIInputStyle = style | |
| 5552 | - | i.applyInputCursorColor() | |
| 5553 | - | if i.cfg.SettingsStore != nil { | |
| 5554 | - | if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil { | |
| 5555 | - | i.mu.Lock() | |
| 5556 | - | i.statusErr = "settings: " + err.Error() | |
| 5557 | - | i.mu.Unlock() | |
| 5558 | - | return | |
| 5559 | - | } | |
| 5560 | - | } | |
| 5561 | - | i.mu.Lock() | |
| 5562 | - | i.statusOK = "input style " + style | |
| 5563 | - | i.statusErr = "" | |
| 5564 | - | i.mu.Unlock() | |
| 5565 | - | } | |
| 1416 | + | // isBoxBlankLine reports whether line is visually empty after | |
| 1417 | + | // stripping ANSI escape sequences, surrounding whitespace, and the | |
| 1418 | + | // vertical box edges drawn by the tool-box renderer. Used by | |
| 1419 | + | // clipBottomClippedImages so an image's reservation rows still count | |
| 1420 | + | // as blank when those rows are wrapped in "│ ... │" inside a tool box. | |
| 5566 | 1421 | ||
| 5567 | - | func (i *Interactive) applyTUIStatusPositionSetting(position string) { | |
| 5568 | - | defer func() { | |
| 5569 | - | i.requestRendererClear() | |
| 5570 | - | i.invalidate() | |
| 5571 | - | }() | |
| 5572 | - | position = tui.NormalizeStatusPosition(position) | |
| 5573 | - | i.cfg.TUIStatusPosition = position | |
| 5574 | - | if i.cfg.SettingsStore != nil { | |
| 5575 | - | if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil { | |
| 5576 | - | i.mu.Lock() | |
| 5577 | - | i.statusErr = "settings: " + err.Error() | |
| 5578 | - | i.mu.Unlock() | |
| 5579 | - | return | |
| 5580 | - | } | |
| 5581 | - | } | |
| 5582 | - | i.mu.Lock() | |
| 5583 | - | label := "above input" | |
| 5584 | - | if position == tui.StatusPositionBelowInput { | |
| 5585 | - | label = "below input" | |
| 5586 | - | } | |
| 5587 | - | i.statusOK = "status position " + label | |
| 5588 | - | i.statusErr = "" | |
| 5589 | - | i.mu.Unlock() | |
| 5590 | - | } | |
| 1422 | + | // stripANSIBytes removes ANSI CSI escape sequences (ESC '[' ... final | |
| 1423 | + | // byte) from s without pulling in the regexp package. Mirrors the | |
| 1424 | + | // internal helper in package tui; the duplicated copy avoids exporting | |
| 1425 | + | // it just for one caller. | |
| 5591 | 1426 | ||
| 5592 | - | func (i *Interactive) applyTUIWorkingPositionSetting(position string) { | |
| 5593 | - | defer func() { | |
| 5594 | - | i.requestRendererClear() | |
| 5595 | - | i.invalidate() | |
| 5596 | - | }() | |
| 5597 | - | position = tui.NormalizeWorkingPosition(position) | |
| 5598 | - | i.cfg.TUIWorkingPosition = position | |
| 5599 | - | if i.cfg.SettingsStore != nil { | |
| 5600 | - | if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil { | |
| 5601 | - | i.mu.Lock() | |
| 5602 | - | i.statusErr = "settings: " + err.Error() | |
| 5603 | - | i.mu.Unlock() | |
| 5604 | - | return | |
| 5605 | - | } | |
| 5606 | - | } | |
| 5607 | - | i.mu.Lock() | |
| 5608 | - | label := "above input" | |
| 5609 | - | if position == tui.WorkingPositionBelowInput { | |
| 5610 | - | label = "below input" | |
| 5611 | - | } | |
| 5612 | - | i.statusOK = "working spinner position " + label | |
| 5613 | - | i.statusErr = "" | |
| 5614 | - | i.mu.Unlock() | |
| 5615 | - | } | |
| 1427 | + | // truncateLine shortens s so it fits within n display cells, with an | |
| 1428 | + | // ellipsis if trimmed. Used by the "sliding in" chips so a pasted | |
| 1429 | + | // novel doesn't blow past the status line. | |
| 5616 | 1430 | ||
| 5617 | - | func (i *Interactive) applyThemeSetting(name string) { | |
| 5618 | - | if i.cfg.SettingsStore != nil { | |
| 5619 | - | if err := i.cfg.SettingsStore.SetTheme(name); err != nil { | |
| 5620 | - | i.mu.Lock() | |
| 5621 | - | i.statusErr = "settings: " + err.Error() | |
| 5622 | - | i.mu.Unlock() | |
| 5623 | - | return | |
| 5624 | - | } | |
| 5625 | - | } | |
| 5626 | - | i.cfg.ThemeName = name | |
| 5627 | - | if name == "auto" { | |
| 5628 | - | i.cfg.ThemeName = "" | |
| 5629 | - | } | |
| 5630 | - | i.applyThemeNow(name) | |
| 5631 | - | } | |
| 1431 | + | // ctrlCExitWindow is how long after a ctrl+c press a *second* press | |
| 1432 | + | // will exit instead of just clearing input. Long enough to be | |
| 1433 | + | // deliberate (rules out accidental key chord), short enough that the | |
| 1434 | + | // hint stays meaningful. | |
| 1435 | + | const ctrlCExitWindow = 2 * time.Second | |
| 5632 | 1436 | ||
| 5633 | - | func (i *Interactive) applyThemeNow(name string) { | |
| 5634 | - | if name == "" { | |
| 5635 | - | name = "auto" | |
| 5636 | - | } | |
| 5637 | - | detected := tui.Dark | |
| 5638 | - | if tui.IsLightTheme(i.cfg.Theme) { | |
| 5639 | - | detected = tui.Light | |
| 5640 | - | } | |
| 5641 | - | // Keep the startup terminal snapshot available when a user switches | |
| 5642 | - | // themes live; inherited mode must not issue OSC queries while the TUI | |
| 5643 | - | // already owns raw stdin. | |
| 5644 | - | detected.Terminal = i.cfg.Theme.Terminal | |
| 5645 | - | th, applied, err := tui.LoadThemeFromHome(i.cfg.ZutHome, name, detected) | |
| 5646 | - | if err != nil { | |
| 5647 | - | if i.cfg.SettingsStore != nil { | |
| 5648 | - | _ = i.cfg.SettingsStore.SetTheme("auto") | |
| 5649 | - | } | |
| 5650 | - | i.cfg.ThemeName = "" | |
| 5651 | - | th, _, _ = tui.LoadThemeFromHome(i.cfg.ZutHome, "auto", detected) | |
| 5652 | - | i.mu.Lock() | |
| 5653 | - | i.statusErr = "theme missing; reset to default" | |
| 5654 | - | i.mu.Unlock() | |
| 5655 | - | } else { | |
| 5656 | - | i.mu.Lock() | |
| 5657 | - | label := applied | |
| 5658 | - | if label == "" { | |
| 5659 | - | label = "auto" | |
| 5660 | - | } | |
| 5661 | - | i.statusOK = "theme " + label | |
| 5662 | - | i.statusErr = "" | |
| 5663 | - | i.mu.Unlock() | |
| 5664 | - | } | |
| 5665 | - | i.mu.Lock() | |
| 5666 | - | i.cfg.Theme = th | |
| 5667 | - | i.view.Theme = th | |
| 5668 | - | i.view.InvalidateRenderCache() | |
| 5669 | - | i.mu.Unlock() | |
| 5670 | - | i.ed.Prompt = th.AccentBar(th.Accent) | |
| 5671 | - | i.applyInputCursorColor() | |
| 5672 | - | i.spin.Configure(th) | |
| 5673 | - | i.requestRendererTheme(th) | |
| 5674 | - | i.requestRendererClear() | |
| 5675 | - | i.invalidate() | |
| 5676 | - | } | |
| 1437 | + | // armCtrlCExit records the timestamp of the current ctrl+c so the next | |
| 1438 | + | // one within ctrlCExitWindow exits. | |
| 5677 | 1439 | ||
| 5678 | - | func (i *Interactive) applyReasoningSetting(level string) { | |
| 5679 | - | defer func() { | |
| 5680 | - | i.requestRendererClear() | |
| 5681 | - | i.invalidate() | |
| 5682 | - | }() | |
| 5683 | - | level = provider.NormalizeReasoning(level) | |
| 5684 | - | i.cfg.Reasoning = level | |
| 5685 | - | if i.cfg.SettingsStore != nil { | |
| 5686 | - | if err := i.cfg.SettingsStore.SetReasoning(level); err != nil { | |
| 5687 | - | i.mu.Lock() | |
| 5688 | - | i.statusErr = "settings: " + err.Error() | |
| 5689 | - | i.mu.Unlock() | |
| 5690 | - | return | |
| 5691 | - | } | |
| 5692 | - | } | |
| 5693 | - | if i.cfg.OnReasoningChanged != nil { | |
| 5694 | - | i.cfg.OnReasoningChanged(level) | |
| 5695 | - | } | |
| 5696 | - | i.mu.Lock() | |
| 5697 | - | if i.agent != nil { | |
| 5698 | - | i.agent.Reasoning = level | |
| 5699 | - | } | |
| 5700 | - | label := level | |
| 5701 | - | if label == "" { | |
| 5702 | - | label = "off" | |
| 5703 | - | } | |
| 5704 | - | i.statusOK = "reasoning level " + label | |
| 5705 | - | i.statusErr = "" | |
| 5706 | - | i.mu.Unlock() | |
| 5707 | - | } | |
| 1440 | + | // ctrlCExitArmed reports whether a previous ctrl+c was recent enough | |
| 1441 | + | // that another press should now exit. | |
| 1442 | + | ||
| 1443 | + | // clearFileSuggestQuery strips the filter the user typed after the | |
| 1444 | + | // last "@", leaving the bare "@" so the picker stays open. Called when | |
| 1445 | + | // navigating between directory levels (Right/Left): the filter applied | |
| 1446 | + | // to the level the user was on, not the one being entered, so carrying | |
| 1447 | + | // it forward would wrongly hide the new directory's contents. | |
| 1448 | + | ||
| 1449 | + | // setToolExpansion updates long tool result expansion and replays the | |
| 1450 | + | // transcript so already-emitted rows reflect the new state. | |
| 1451 | + | ||
| 1452 | + | // confirmChildActive reports whether an interaction opened from slash input | |
| 1453 | + | // currently owns the keyboard while a tool confirmation remains pending. | |
| 1454 | + | ||
| 1455 | + | // restoreConfirmFocus returns input to confirmation after slash input is | |
| 1456 | + | // cleared or a child interaction closes. A non-empty editor keeps command | |
| 1457 | + | // input focused, and an active child continues to own its keys. | |
| 1458 | + | ||
| 1459 | + | // invokeExtensionCommand fires an extension-registered slash command | |
| 1460 | + | // in a background goroutine, awaits the response, and applies the | |
| 1461 | + | // requested action (prompt / insert / display / noop). Errors and | |
| 1462 | + | // timeouts surface as a status_err line. | |
| 1463 | + | ||
| 1464 | + | // appendExtensionNote renders an extension-originated note in the | |
| 1465 | + | // chat. Levels: "info" (muted), "warn" (warning), "error" (error), | |
| 1466 | + | // "success" (tool/ok green). | |
| 1467 | + | ||
| 1468 | + | // HostHooks implementation for the extension manager. The manager | |
| 1469 | + | // holds an interface, not a concrete *Interactive, so these methods | |
| 1470 | + | // are the only thing the manager sees. | |
| 1471 | + | ||
| 1472 | + | // ApplySessionAgent swaps the live agent after a validated session resume. | |
| 1473 | + | // Unlike ApplyChangedCWD it does not mutate cwd-scoped resources or startup | |
| 1474 | + | // context; the session loader is changing the transcript/provider only. | |
| 1475 | + | ||
| 1476 | + | // SetSubagentSessionScope redraws after a host session transition. Resident | |
| 1477 | + | // children retain their explicit parent session in their durable journal. | |
| 1478 | + | ||
| 1479 | + | // ApplyChangedCWD is called by hosts after a successful /cd hook that do | |
| 1480 | + | // not provide startup context metadata. | |
| 1481 | + | ||
| 1482 | + | // ApplyChangedCWDWithStartupContext swaps a rebuilt agent and its display-only | |
| 1483 | + | // startup context into the running TUI after a successful /cd hook. | |
| 1484 | + | ||
| 1485 | + | // SubmitSlash runs text as a slash command in the TUI as if the user | |
| 1486 | + | // had typed it. text must start with '/' — callers that hand it | |
| 1487 | + | // plain prose silently get a no-op so a misbehaving extension can't | |
| 1488 | + | // run a stray prompt through this path. Read-only commands run in | |
| 1489 | + | // place; commands that would mutate the transcript or replace the | |
| 1490 | + | // agent cancel the active turn first via the same path the editor | |
| 1491 | + | // uses for typed slash commands. | |
| 1492 | + | ||
| 1493 | + | // SubmitOrQueue runs text immediately if the agent is idle, or | |
| 1494 | + | // appends it to the pending queue if a turn is already in flight. | |
| 1495 | + | // Used by the telegram bridge (and by the editor submit path) so | |
| 1496 | + | // both input sources share the same "queue behind an active turn" | |
| 1497 | + | // semantics. Images are ignored for now — only the text prompt is | |
| 1498 | + | // forwarded — because the queued-prompt path is text-only; a | |
| 1499 | + | // follow-up can expand the queue entry to carry images. | |
| 1500 | + | ||
| 1501 | + | // CancelTurn aborts the active turn if one is running. Used by the | |
| 1502 | + | // telegram bridge when the paired user sends /stop. | |
| 1503 | + | // ChangelogVersion returns the version string of the changelog | |
| 1504 | + | // currently shown (or last shown). Used by the dismiss callback | |
| 1505 | + | // to store the correct version for dev builds. | |
| 1506 | + | ||
| 1507 | + | // Insert places text at the cursor in the editor. | |
| 1508 | + | ||
| 1509 | + | // Display appends a styled note from extName to the chat without a | |
| 1510 | + | // model call. | |
| 1511 | + | ||
| 1512 | + | // ReportError surfaces a host-side error in the interactive status area. | |
| 1513 | + | ||
| 1514 | + | // SetStatus replaces one persistent status item owned by an extension. | |
| 1515 | + | ||
| 1516 | + | // SetWidget replaces one persistent widget owned by an extension. | |
| 1517 | + | ||
| 1518 | + | // ClearWidget removes one persistent widget owned by an extension. | |
| 1519 | + | ||
| 1520 | + | // ClearExtensionChrome removes every persistent UI item owned by an | |
| 1521 | + | // extension that has exited. This keeps crashes, disable/reload operations, | |
| 1522 | + | // and startup failures from leaving stale widgets beside the transcript. | |
| 1523 | + | ||
| 1524 | + | const modalBackdropDimPercent = 50 | |
| 1525 | + | ||
| 1526 | + | // setInputCursorDimmed updates the terminal-owned cursor only when its layer | |
| 1527 | + | // changes. Reapplying cursor controls on every redraw can reset its blink | |
| 1528 | + | // timing before the terminal completes a normal blink cycle. | |
| 1529 | + | ||
| 1530 | + | // stripWebSearchTool removes the callable web_search entry from the live | |
| 1531 | + | // registry without asking the resolver to run again. It is the fail-closed | |
| 1532 | + | // fallback when persistence rollback fails after a refresh failure. | |
| 5708 | 1533 | ||
| 5709 | 1534 | // buildStudyPrompt returns the canned prompt the /study command | |
| 5710 | 1535 | // submits to the agent. | |
| @@ -5724,24 +1549,6 @@ func (i *Interactive) applyReasoningSetting(level string) { | |||
| 5724 | 1549 | // prompt rather than erroring — the agent will surface the | |
| 5725 | 1550 | // missing-file failure itself when it tries to read it, which is | |
| 5726 | 1551 | // more useful than a refusal here. | |
| 5727 | - | func buildStudyPrompt(arg, cwd string) string { | |
| 5728 | - | arg = strings.TrimSpace(arg) | |
| 5729 | - | if arg == "" { | |
| 5730 | - | return "Read and understand everything in the current directory." | |
| 5731 | - | } | |
| 5732 | - | abs := arg | |
| 5733 | - | if !filepath.IsAbs(abs) { | |
| 5734 | - | abs = filepath.Join(cwd, abs) | |
| 5735 | - | } | |
| 5736 | - | display := arg | |
| 5737 | - | if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") { | |
| 5738 | - | display = rel | |
| 5739 | - | } | |
| 5740 | - | if info, err := os.Stat(abs); err == nil && !info.IsDir() { | |
| 5741 | - | return "Read and understand the file " + display + "." | |
| 5742 | - | } | |
| 5743 | - | return "Read and understand everything in the directory " + display + "." | |
| 5744 | - | } | |
| 5745 | 1552 | ||
| 5746 | 1553 | // tryPathTabCompleteEditor looks at ed's current value, finds the | |
| 5747 | 1554 | // path-like token immediately before the cursor (the cursor is always | |
| @@ -5766,548 +1573,30 @@ func buildStudyPrompt(arg, cwd string) string { | |||
| 5766 | 1573 | // Free function (not a method) so the same logic runs against the | |
| 5767 | 1574 | // editor instances owned by local dialogs without each | |
| 5768 | 1575 | // dialog needing its own copy. | |
| 5769 | - | func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool { | |
| 5770 | - | if ed == nil { | |
| 5771 | - | return false | |
| 5772 | - | } | |
| 5773 | - | val := ed.Value() | |
| 5774 | - | // Find the trailing run of non-whitespace. | |
| 5775 | - | start := len(val) | |
| 5776 | - | for start > 0 { | |
| 5777 | - | r := val[start-1] | |
| 5778 | - | if r == ' ' || r == '\t' || r == '\n' { | |
| 5779 | - | break | |
| 5780 | - | } | |
| 5781 | - | start-- | |
| 5782 | - | } | |
| 5783 | - | token := val[start:] | |
| 5784 | - | if token == "" { | |
| 5785 | - | return false | |
| 5786 | - | } | |
| 5787 | - | if !looksLikePathToken(token) { | |
| 5788 | - | return false | |
| 5789 | - | } | |
| 5790 | - | ||
| 5791 | - | // Resolve the absolute parent directory + base prefix to match. | |
| 5792 | - | parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd) | |
| 5793 | - | if !ok { | |
| 5794 | - | return true | |
| 5795 | - | } | |
| 5796 | - | entries, err := os.ReadDir(parentAbs) | |
| 5797 | - | if err != nil { | |
| 5798 | - | return true | |
| 5799 | - | } | |
| 5800 | - | var names []string | |
| 5801 | - | var isDir []bool | |
| 5802 | - | for _, e := range entries { | |
| 5803 | - | name := e.Name() | |
| 5804 | - | if !strings.HasPrefix(name, basePrefix) { | |
| 5805 | - | continue | |
| 5806 | - | } | |
| 5807 | - | // Hide dotfiles unless the user explicitly typed a leading dot, | |
| 5808 | - | // mirroring bash's default behaviour. | |
| 5809 | - | if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") { | |
| 5810 | - | continue | |
| 5811 | - | } | |
| 5812 | - | names = append(names, name) | |
| 5813 | - | isDir = append(isDir, e.IsDir()) | |
| 5814 | - | } | |
| 5815 | - | if len(names) == 0 { | |
| 5816 | - | return true | |
| 5817 | - | } | |
| 5818 | - | ||
| 5819 | - | var completed string | |
| 5820 | - | var completedIsDir bool | |
| 5821 | - | if len(names) == 1 { | |
| 5822 | - | completed = names[0] | |
| 5823 | - | completedIsDir = isDir[0] | |
| 5824 | - | } else { | |
| 5825 | - | completed = longestCommonPrefix(names) | |
| 5826 | - | if completed == basePrefix { | |
| 5827 | - | // Already at the deepest unambiguous prefix; nothing to add. | |
| 5828 | - | return true | |
| 5829 | - | } | |
| 5830 | - | } | |
| 5831 | - | ||
| 5832 | - | // Build the replacement token in the same display form the user | |
| 5833 | - | // typed (preserve ~ vs absolute vs relative). | |
| 5834 | - | newToken := displayParent + completed | |
| 5835 | - | if len(names) == 1 && completedIsDir { | |
| 5836 | - | newToken += "/" | |
| 5837 | - | } | |
| 5838 | - | ||
| 5839 | - | ed.SetValue(val[:start] + newToken) | |
| 5840 | - | return true | |
| 5841 | - | } | |
| 5842 | 1576 | ||
| 5843 | 1577 | // tryPathTabComplete is the Interactive-bound convenience wrapper. | |
| 5844 | - | // It calls the free helper against the main editor and invalidates | |
| 5845 | - | // the frame on a successful rewrite. | |
| 5846 | - | func (i *Interactive) tryPathTabComplete() bool { | |
| 5847 | - | if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) { | |
| 5848 | - | i.invalidate() | |
| 5849 | - | return true | |
| 5850 | - | } | |
| 5851 | - | return false | |
| 5852 | - | } | |
| 5853 | - | ||
| 5854 | - | // looksLikePathToken reports whether tok is shaped like a filesystem | |
| 5855 | - | // path. Paths must either start with ~, /, ./, ../ or contain a /. | |
| 5856 | - | // Plain words are excluded so Tab on "hello" stays a no-op. | |
| 5857 | - | func looksLikePathToken(tok string) bool { | |
| 5858 | - | if tok == "" { | |
| 5859 | - | return false | |
| 5860 | - | } | |
| 5861 | - | if tok[0] == '~' || tok[0] == '/' { | |
| 5862 | - | return true | |
| 5863 | - | } | |
| 5864 | - | if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") { | |
| 5865 | - | return true | |
| 5866 | - | } | |
| 5867 | - | return strings.Contains(tok, "/") | |
| 5868 | - | } | |
| 5869 | - | ||
| 5870 | - | // resolvePathTabToken splits tok into (absolute parent dir, basename | |
| 5871 | - | // prefix to match, display-form parent the user typed). ok is false | |
| 5872 | - | // when the parent dir can't be resolved (e.g. ~ with no $HOME). | |
| 5873 | - | func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) { | |
| 5874 | - | // Detect ~ expansion. | |
| 5875 | - | expanded := tok | |
| 5876 | - | homePrefix := "" | |
| 5877 | - | if tok == "~" { | |
| 5878 | - | home, err := os.UserHomeDir() | |
| 5879 | - | if err != nil || home == "" { | |
| 5880 | - | return "", "", "", false | |
| 5881 | - | } | |
| 5882 | - | // "~" alone: complete in $HOME. parent = home, base = "". | |
| 5883 | - | return home, "", "~/", true | |
| 5884 | - | } | |
| 5885 | - | if strings.HasPrefix(tok, "~/") { | |
| 5886 | - | home, err := os.UserHomeDir() | |
| 5887 | - | if err != nil || home == "" { | |
| 5888 | - | return "", "", "", false | |
| 5889 | - | } | |
| 5890 | - | expanded = home + tok[1:] | |
| 5891 | - | homePrefix = "~" | |
| 5892 | - | } | |
| 5893 | - | ||
| 5894 | - | dir, base := splitDirBase(expanded) | |
| 5895 | - | if !filepath.IsAbs(dir) { | |
| 5896 | - | dir = filepath.Join(cwd, dir) | |
| 5897 | - | } | |
| 5898 | - | ||
| 5899 | - | // Reconstruct the display form the user typed for the parent, | |
| 5900 | - | // keeping ~ when they used it. The base is dropped — the caller | |
| 5901 | - | // substitutes the completed name. | |
| 5902 | - | display := tok[:len(tok)-len(base)] | |
| 5903 | - | if homePrefix != "" && !strings.HasPrefix(display, "~") { | |
| 5904 | - | display = homePrefix + display[len(homePrefix):] | |
| 5905 | - | } | |
| 5906 | - | return dir, base, display, true | |
| 5907 | - | } | |
| 5908 | - | ||
| 5909 | - | // splitDirBase is like filepath.Split but preserves the trailing | |
| 5910 | - | // slash convention: "foo" => (".", "foo"); "foo/" => ("foo", ""); | |
| 5911 | - | // "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has | |
| 5912 | - | // the trailing separator when non-empty so callers can rebuild paths | |
| 5913 | - | // by concatenation. | |
| 5914 | - | func splitDirBase(p string) (dir, base string) { | |
| 5915 | - | if p == "" { | |
| 5916 | - | return ".", "" | |
| 5917 | - | } | |
| 5918 | - | i := strings.LastIndex(p, "/") | |
| 5919 | - | if i < 0 { | |
| 5920 | - | return ".", p | |
| 5921 | - | } | |
| 5922 | - | return p[:i+1], p[i+1:] | |
| 5923 | - | } | |
| 5924 | - | ||
| 5925 | - | func longestCommonPrefix(ss []string) string { | |
| 5926 | - | if len(ss) == 0 { | |
| 5927 | - | return "" | |
| 5928 | - | } | |
| 5929 | - | prefix := ss[0] | |
| 5930 | - | for _, s := range ss[1:] { | |
| 5931 | - | n := 0 | |
| 5932 | - | for n < len(prefix) && n < len(s) && prefix[n] == s[n] { | |
| 5933 | - | n++ | |
| 5934 | - | } | |
| 5935 | - | prefix = prefix[:n] | |
| 5936 | - | if prefix == "" { | |
| 5937 | - | return "" | |
| 5938 | - | } | |
| 5939 | - | } | |
| 5940 | - | return prefix | |
| 5941 | - | } | |
| 5942 | - | ||
| 5943 | - | func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |
| 5944 | - | parts := strings.Fields(cmd) | |
| 5945 | - | if len(parts) == 0 { | |
| 5946 | - | return false | |
| 5947 | - | } | |
| 5948 | - | ||
| 5949 | - | if isSkillCommand(cmd) { | |
| 5950 | - | var available []*skills.Skill | |
| 5951 | - | if i.cfg.SkillSnapshot != nil { | |
| 5952 | - | available = i.cfg.SkillSnapshot() | |
| 5953 | - | } | |
| 5954 | - | prompt, _, err := expandSkillCommand(cmd, available) | |
| 5955 | - | if err != nil { | |
| 5956 | - | i.mu.Lock() | |
| 5957 | - | i.statusErr = err.Error() | |
| 5958 | - | i.statusOK = "" | |
| 5959 | - | i.mu.Unlock() | |
| 5960 | - | return false | |
| 5961 | - | } | |
| 5962 | - | i.submitOrQueuePrompt(ctx, prompt) | |
| 5963 | - | return false | |
| 5964 | - | } | |
| 1578 | + | // It calls the free helper against the main editor and invalidates | |
| 1579 | + | // the frame on a successful rewrite. | |
| 5965 | 1580 | ||
| 5966 | - | switch strings.ToLower(parts[0]) { | |
| 5967 | - | case "/goal": | |
| 5968 | - | i.runGoalCommand(ctx, cmd, parts) | |
| 5969 | - | case "/exit": | |
| 5970 | - | return true | |
| 5971 | - | case "/clear": | |
| 5972 | - | if i.agent != nil { | |
| 5973 | - | i.agent.SetMessages(nil) | |
| 5974 | - | } | |
| 5975 | - | i.mu.Lock() | |
| 5976 | - | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 5977 | - | i.toolOrder = nil | |
| 5978 | - | i.toolGate = map[string]int{} | |
| 5979 | - | i.statusErr = "" | |
| 5980 | - | i.statusOK = "" | |
| 5981 | - | i.helpBlock = nil | |
| 5982 | - | i.parkedTurn = 0 | |
| 5983 | - | i.parkedTotal = 0 | |
| 5984 | - | i.scrollOffset = 0 | |
| 5985 | - | i.extNotes = nil | |
| 5986 | - | i.reloadErrors = nil | |
| 5987 | - | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 5988 | - | i.view.InvalidateRenderCache() | |
| 5989 | - | i.mu.Unlock() | |
| 5990 | - | if persistHandoff { | |
| 5991 | - | i.persistCompactHandoff(handoff) | |
| 5992 | - | } | |
| 5993 | - | case "/help": | |
| 5994 | - | i.mu.Lock() | |
| 5995 | - | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured) | |
| 5996 | - | i.statusErr = "" | |
| 5997 | - | i.statusOK = "" | |
| 5998 | - | // Pin the viewport to the newest content so the help block, | |
| 5999 | - | // which we just appended to the end of the transcript, is | |
| 6000 | - | // what the user actually sees. | |
| 6001 | - | i.scrollOffset = 0 | |
| 6002 | - | i.mu.Unlock() | |
| 6003 | - | case "/login": | |
| 6004 | - | i.dialog.Open(i.cfg.ZutHome) | |
| 6005 | - | case "/logout": | |
| 6006 | - | if len(parts) >= 2 { | |
| 6007 | - | // Explicit target: /logout anthropic | openai | all | |
| 6008 | - | i.doLogout(parts[1]) | |
| 6009 | - | break | |
| 6010 | - | } | |
| 6011 | - | // No arg: open the picker over whichever providers are | |
| 6012 | - | // currently logged in. If nothing's logged in, bail with a | |
| 6013 | - | // status line. | |
| 6014 | - | i.openLogoutDialog() | |
| 6015 | - | case "/model": | |
| 6016 | - | if len(parts) >= 2 { | |
| 6017 | - | i.applyModelSelection("", parts[1]) | |
| 6018 | - | } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil { | |
| 6019 | - | i.mu.Lock() | |
| 6020 | - | i.modelRefreshing = true | |
| 6021 | - | i.statusOK = "Refreshing models" | |
| 6022 | - | i.statusErr = "" | |
| 6023 | - | i.mu.Unlock() | |
| 6024 | - | go func() { | |
| 6025 | - | refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 6026 | - | defer cancel() | |
| 6027 | - | i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)} | |
| 6028 | - | }() | |
| 6029 | - | } else { | |
| 6030 | - | i.openModelPickerAfterRefresh(nil) | |
| 6031 | - | } | |
| 6032 | - | case "/llama": | |
| 6033 | - | if i.cfg.LlamaCPPConfig == nil { | |
| 6034 | - | i.mu.Lock() | |
| 6035 | - | i.statusErr = "llama.cpp configuration is unavailable" | |
| 6036 | - | i.statusOK = "" | |
| 6037 | - | i.mu.Unlock() | |
| 6038 | - | break | |
| 6039 | - | } | |
| 6040 | - | baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig() | |
| 6041 | - | if configErr == nil { | |
| 6042 | - | configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate) | |
| 6043 | - | } | |
| 6044 | - | if configErr != nil { | |
| 6045 | - | i.mu.Lock() | |
| 6046 | - | i.statusErr = configErr.Error() | |
| 6047 | - | i.statusOK = "" | |
| 6048 | - | i.mu.Unlock() | |
| 6049 | - | } | |
| 6050 | - | case "/reasoning": | |
| 6051 | - | i.openReasoningDialog() | |
| 6052 | - | case "/fast": | |
| 6053 | - | enabled := i.cfg.FastMode == nil || !*i.cfg.FastMode | |
| 6054 | - | i.applySettingToggle("fast_mode", enabled) | |
| 6055 | - | case "/orchestrator": | |
| 6056 | - | enabled := i.cfg.AutoSubagentsEnabled == nil || !*i.cfg.AutoSubagentsEnabled | |
| 6057 | - | i.applySettingToggle("auto_subagents_enabled", enabled) | |
| 6058 | - | case "/settings": | |
| 6059 | - | i.openSettingsDialog() | |
| 6060 | - | case "/sessions": | |
| 6061 | - | i.mu.Lock() | |
| 6062 | - | i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD) | |
| 6063 | - | i.mu.Unlock() | |
| 6064 | - | case "/fork": | |
| 6065 | - | i.doSessionFork() | |
| 6066 | - | case "/jump": | |
| 6067 | - | i.openJumpDialog(parts[1:]) | |
| 6068 | - | case "/btw": | |
| 6069 | - | i.openBtwDialog(parts[1:]) | |
| 6070 | - | case "/skills": | |
| 6071 | - | i.openSkillsDialog() | |
| 6072 | - | case "/compact": | |
| 6073 | - | i.runCompact(ctx, compactContinuationRequest{origin: compactOriginManual}) | |
| 6074 | - | case "/study": | |
| 6075 | - | // Canned prompt that tells the agent to read every file | |
| 6076 | - | // in some target so its later turns have the whole thing | |
| 6077 | - | // in context. With no argument, the target is the current | |
| 6078 | - | // directory. With an argument, the target is whatever the | |
| 6079 | - | // user passed — typed by hand, drag-dropped, or selected | |
| 6080 | - | // via the @ file picker (which is why we accept both files | |
| 6081 | - | // and directories; the @-picker chips for both have already | |
| 6082 | - | // been expanded to absolute paths by expandFileChips above). | |
| 6083 | - | // Dispatched through the normal queue-or-start path so it | |
| 6084 | - | // behaves identically to typing the prompt by hand. | |
| 6085 | - | studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD) | |
| 6086 | - | i.mu.Lock() | |
| 6087 | - | busy := i.busy | |
| 6088 | - | compacting := i.compacting | |
| 6089 | - | ag := i.agent | |
| 6090 | - | i.mu.Unlock() | |
| 6091 | - | if busy { | |
| 6092 | - | var handoff json.RawMessage | |
| 6093 | - | var persistHandoff bool | |
| 6094 | - | if ag != nil && !compacting { | |
| 6095 | - | i.mu.Lock() | |
| 6096 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 6097 | - | ag.QueueMessage(studyPrompt) | |
| 6098 | - | i.mu.Unlock() | |
| 6099 | - | } else { | |
| 6100 | - | i.mu.Lock() | |
| 6101 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 6102 | - | i.queued = append(i.queued, studyPrompt) | |
| 6103 | - | i.mu.Unlock() | |
| 6104 | - | } | |
| 6105 | - | if persistHandoff { | |
| 6106 | - | i.persistCompactHandoff(handoff) | |
| 6107 | - | } | |
| 6108 | - | i.invalidate() | |
| 6109 | - | break | |
| 6110 | - | } | |
| 6111 | - | i.startTurn(ctx, studyPrompt) | |
| 6112 | - | case "/cd": | |
| 6113 | - | // Hidden command: switch the running session's cwd. Not in | |
| 6114 | - | // slash_suggest, not in /help. Used by the workspaces | |
| 6115 | - | // extension's panel-key Enter handler so picking a row | |
| 6116 | - | // jumps zut into that directory without relaunching. | |
| 6117 | - | // | |
| 6118 | - | // Recovers the raw argument (path) from the original cmd | |
| 6119 | - | // string rather than parts, so paths with spaces survive. | |
| 6120 | - | // The host's ChangeCWD hook handles validation, session | |
| 6121 | - | // close + reopen, agent rebuild, sandbox re-rooting, and | |
| 6122 | - | // re-jail-if-jailed semantics. | |
| 6123 | - | if i.cfg.ChangeCWD == nil { | |
| 6124 | - | i.mu.Lock() | |
| 6125 | - | i.statusErr = "/cd unavailable: host did not wire ChangeCWD" | |
| 6126 | - | i.mu.Unlock() | |
| 6127 | - | break | |
| 6128 | - | } | |
| 6129 | - | path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])) | |
| 6130 | - | if path == "" { | |
| 6131 | - | i.mu.Lock() | |
| 6132 | - | i.statusErr = "/cd: missing path" | |
| 6133 | - | i.mu.Unlock() | |
| 6134 | - | break | |
| 6135 | - | } | |
| 6136 | - | if err := i.cfg.ChangeCWD(path); err != nil { | |
| 6137 | - | i.mu.Lock() | |
| 6138 | - | i.statusErr = "/cd: " + err.Error() | |
| 6139 | - | i.statusOK = "" | |
| 6140 | - | i.mu.Unlock() | |
| 6141 | - | break | |
| 6142 | - | } | |
| 6143 | - | // ChangeCWD has already updated i.cfg.CWD and swapped the | |
| 6144 | - | // agent + session. Reset transient TUI state so the new | |
| 6145 | - | // session opens clean. | |
| 6146 | - | i.mu.Lock() | |
| 6147 | - | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 6148 | - | i.toolOrder = nil | |
| 6149 | - | i.toolGate = map[string]int{} | |
| 6150 | - | i.helpBlock = nil | |
| 6151 | - | i.parkedTurn = 0 | |
| 6152 | - | i.statusOK = "cwd " + i.cfg.CWD | |
| 6153 | - | i.statusErr = "" | |
| 6154 | - | i.mu.Unlock() | |
| 6155 | - | i.fileSuggest.Reset() | |
| 6156 | - | i.fileSuggest.SetCWD(i.cfg.CWD) | |
| 6157 | - | i.invalidate() | |
| 6158 | - | case "/jail": | |
| 6159 | - | if i.cfg.Sandbox == nil { | |
| 6160 | - | i.mu.Lock() | |
| 6161 | - | i.statusErr = "sandbox not available in this build" | |
| 6162 | - | i.mu.Unlock() | |
| 6163 | - | break | |
| 6164 | - | } | |
| 6165 | - | i.cfg.Sandbox.Lock() | |
| 6166 | - | i.mu.Lock() | |
| 6167 | - | i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)" | |
| 6168 | - | i.statusErr = "" | |
| 6169 | - | i.mu.Unlock() | |
| 6170 | - | case "/unjail": | |
| 6171 | - | if i.cfg.Sandbox == nil { | |
| 6172 | - | i.mu.Lock() | |
| 6173 | - | i.statusErr = "sandbox not available in this build" | |
| 6174 | - | i.mu.Unlock() | |
| 6175 | - | break | |
| 6176 | - | } | |
| 6177 | - | i.cfg.Sandbox.Unlock() | |
| 6178 | - | i.mu.Lock() | |
| 6179 | - | i.statusOK = "unjailed" | |
| 6180 | - | i.statusErr = "" | |
| 6181 | - | i.mu.Unlock() | |
| 6182 | - | case "/reload-ext": | |
| 6183 | - | i.runReloadExt(ctx) | |
| 6184 | - | case "/telegram", "/tg": | |
| 6185 | - | if len(parts) >= 2 { | |
| 6186 | - | i.doTelegram(parts[1]) | |
| 6187 | - | break | |
| 6188 | - | } | |
| 6189 | - | i.openTelegramDialog() | |
| 6190 | - | case "/session": | |
| 6191 | - | if len(parts) >= 2 { | |
| 6192 | - | action := parts[1] | |
| 6193 | - | arg := "" | |
| 6194 | - | if len(parts) >= 3 { | |
| 6195 | - | arg = strings.Join(parts[2:], " ") | |
| 6196 | - | } | |
| 6197 | - | i.doSessionOp(action, arg) | |
| 6198 | - | break | |
| 6199 | - | } | |
| 6200 | - | i.openSessionOpsDialog() | |
| 6201 | - | case "/subagents": | |
| 6202 | - | i.runSubagents(ctx, parts[1:]) | |
| 6203 | - | default: | |
| 6204 | - | // Last-resort fallback: try the extension manager. Built-in | |
| 6205 | - | // cases above always win; this branch only fires for slash | |
| 6206 | - | // commands the extension manager registered. Same routing as | |
| 6207 | - | // the editor's submit-handler dispatch path so the autocomplete | |
| 6208 | - | // "enter on highlighted suggestion" flow also works. | |
| 6209 | - | extName := strings.TrimPrefix(parts[0], "/") | |
| 6210 | - | if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) { | |
| 6211 | - | rest := "" | |
| 6212 | - | if len(parts) > 1 { | |
| 6213 | - | rest = strings.Join(parts[1:], " ") | |
| 6214 | - | } | |
| 6215 | - | go i.invokeExtensionCommand(ctx, extName, rest) | |
| 6216 | - | return false | |
| 6217 | - | } | |
| 6218 | - | i.mu.Lock() | |
| 6219 | - | i.statusErr = "unknown command: " + parts[0] | |
| 6220 | - | i.mu.Unlock() | |
| 6221 | - | } | |
| 6222 | - | return false | |
| 6223 | - | } | |
| 1581 | + | // looksLikePathToken reports whether tok is shaped like a filesystem | |
| 1582 | + | // path. Paths must either start with ~, /, ./, ../ or contain a /. | |
| 1583 | + | // Plain words are excluded so Tab on "hello" stays a no-op. | |
| 6224 | 1584 | ||
| 6225 | - | func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) { | |
| 6226 | - | var loggedIn []string | |
| 6227 | - | if i.cfg.LoggedInProviders != nil { | |
| 6228 | - | loggedIn = i.cfg.LoggedInProviders() | |
| 6229 | - | } | |
| 6230 | - | i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning) | |
| 6231 | - | i.mu.Lock() | |
| 6232 | - | if refreshErr != nil { | |
| 6233 | - | i.statusErr = "llama.cpp model refresh: " + refreshErr.Error() | |
| 6234 | - | i.statusOK = "" | |
| 6235 | - | } else if i.statusOK == "Refreshing models" { | |
| 6236 | - | i.statusOK = "" | |
| 6237 | - | } | |
| 6238 | - | i.mu.Unlock() | |
| 6239 | - | } | |
| 1585 | + | // resolvePathTabToken splits tok into (absolute parent dir, basename | |
| 1586 | + | // prefix to match, display-form parent the user typed). ok is false | |
| 1587 | + | // when the parent dir can't be resolved (e.g. ~ with no $HOME). | |
| 1588 | + | ||
| 1589 | + | // splitDirBase is like filepath.Split but preserves the trailing | |
| 1590 | + | // slash convention: "foo" => (".", "foo"); "foo/" => ("foo", ""); | |
| 1591 | + | // "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has | |
| 1592 | + | // the trailing separator when non-empty so callers can rebuild paths | |
| 1593 | + | // by concatenation. | |
| 6240 | 1594 | ||
| 6241 | 1595 | // openLogoutDialog shows the provider picker for `/logout` with no | |
| 6242 | 1596 | // argument. Only providers the user is currently logged into are | |
| 6243 | 1597 | // listed, plus an "all" entry when more than one is present. If | |
| 6244 | 1598 | // nothing's logged in, writes a status line instead of opening an | |
| 6245 | 1599 | // empty dialog. | |
| 6246 | - | func (i *Interactive) openLogoutDialog() { | |
| 6247 | - | if i.cfg.AuthManager == nil { | |
| 6248 | - | i.mu.Lock() | |
| 6249 | - | i.statusErr = "no auth manager configured" | |
| 6250 | - | i.mu.Unlock() | |
| 6251 | - | i.invalidate() | |
| 6252 | - | return | |
| 6253 | - | } | |
| 6254 | - | store := i.cfg.AuthManager.Store() | |
| 6255 | - | if store == nil { | |
| 6256 | - | i.mu.Lock() | |
| 6257 | - | i.statusErr = "auth store is not available" | |
| 6258 | - | i.mu.Unlock() | |
| 6259 | - | i.invalidate() | |
| 6260 | - | return | |
| 6261 | - | } | |
| 6262 | - | creds, err := store.Load() | |
| 6263 | - | if err != nil { | |
| 6264 | - | i.mu.Lock() | |
| 6265 | - | i.statusErr = "read auth store: " + err.Error() | |
| 6266 | - | i.mu.Unlock() | |
| 6267 | - | i.invalidate() | |
| 6268 | - | return | |
| 6269 | - | } | |
| 6270 | - | ||
| 6271 | - | var items []logoutItem | |
| 6272 | - | for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} { | |
| 6273 | - | if creds.Has(p) { | |
| 6274 | - | method := creds.Method(p) | |
| 6275 | - | if method == "oauth" { | |
| 6276 | - | method = "subscription" | |
| 6277 | - | } | |
| 6278 | - | items = append(items, logoutItem{ | |
| 6279 | - | label: providerLabel(p), | |
| 6280 | - | target: p, | |
| 6281 | - | method: method, | |
| 6282 | - | }) | |
| 6283 | - | } | |
| 6284 | - | } | |
| 6285 | - | if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil { | |
| 6286 | - | items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"}) | |
| 6287 | - | } | |
| 6288 | - | if creds.OpenAI.OAuth != nil { | |
| 6289 | - | items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"}) | |
| 6290 | - | } | |
| 6291 | - | for p, c := range creds.AdditionalAPIKeyCreds { | |
| 6292 | - | if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" { | |
| 6293 | - | items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"}) | |
| 6294 | - | } | |
| 6295 | - | } | |
| 6296 | - | if len(items) == 0 { | |
| 6297 | - | i.mu.Lock() | |
| 6298 | - | i.statusOK = "no credentials stored; already logged out" | |
| 6299 | - | i.statusErr = "" | |
| 6300 | - | i.mu.Unlock() | |
| 6301 | - | i.invalidate() | |
| 6302 | - | return | |
| 6303 | - | } | |
| 6304 | - | if len(items) > 1 { | |
| 6305 | - | items = append(items, logoutItem{label: "all", target: "all"}) | |
| 6306 | - | } | |
| 6307 | - | ||
| 6308 | - | i.logoutDialog.Open(items) | |
| 6309 | - | i.invalidate() | |
| 6310 | - | } | |
| 6311 | 1600 | ||
| 6312 | 1601 | // doLogout clears credentials for the given provider (or all providers) | |
| 6313 | 1602 | // from auth.json. If the active agent was using those credentials, it | |
| @@ -6315,263 +1604,6 @@ func (i *Interactive) openLogoutDialog() { | |||
| 6315 | 1604 | // prompt. | |
| 6316 | 1605 | // | |
| 6317 | 1606 | // target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all" | |
| 6318 | - | func (i *Interactive) doLogout(target string) { | |
| 6319 | - | if i.cfg.AuthManager == nil { | |
| 6320 | - | i.mu.Lock() | |
| 6321 | - | i.statusErr = "no auth manager configured" | |
| 6322 | - | i.mu.Unlock() | |
| 6323 | - | return | |
| 6324 | - | } | |
| 6325 | - | store := i.cfg.AuthManager.Store() | |
| 6326 | - | if store == nil { | |
| 6327 | - | i.mu.Lock() | |
| 6328 | - | i.statusErr = "auth store is not available" | |
| 6329 | - | i.mu.Unlock() | |
| 6330 | - | return | |
| 6331 | - | } | |
| 6332 | - | ||
| 6333 | - | var providers []string | |
| 6334 | - | switch target { | |
| 6335 | - | case "", "all": | |
| 6336 | - | providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...) | |
| 6337 | - | case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot": | |
| 6338 | - | providers = []string{target} | |
| 6339 | - | default: | |
| 6340 | - | known := false | |
| 6341 | - | for _, p := range auth.APIKeyProviders() { | |
| 6342 | - | if target == p { | |
| 6343 | - | known = true | |
| 6344 | - | break | |
| 6345 | - | } | |
| 6346 | - | } | |
| 6347 | - | if !known { | |
| 6348 | - | i.mu.Lock() | |
| 6349 | - | i.statusErr = "unknown provider: " + target | |
| 6350 | - | i.mu.Unlock() | |
| 6351 | - | return | |
| 6352 | - | } | |
| 6353 | - | providers = []string{target} | |
| 6354 | - | } | |
| 6355 | - | ||
| 6356 | - | var errs []string | |
| 6357 | - | clearedCurrent := false | |
| 6358 | - | for _, p := range providers { | |
| 6359 | - | var err error | |
| 6360 | - | switch p { | |
| 6361 | - | case "openai": | |
| 6362 | - | err = store.ClearAPIKey("openai") | |
| 6363 | - | case "openai-codex": | |
| 6364 | - | err = store.ClearOAuth("openai") | |
| 6365 | - | default: | |
| 6366 | - | err = store.Clear(p) | |
| 6367 | - | } | |
| 6368 | - | if err != nil { | |
| 6369 | - | errs = append(errs, p+": "+err.Error()) | |
| 6370 | - | continue | |
| 6371 | - | } | |
| 6372 | - | if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { | |
| 6373 | - | if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil { | |
| 6374 | - | errs = append(errs, p+": "+err.Error()) | |
| 6375 | - | continue | |
| 6376 | - | } | |
| 6377 | - | } | |
| 6378 | - | if p == i.cfg.Provider { | |
| 6379 | - | clearedCurrent = true | |
| 6380 | - | } | |
| 6381 | - | } | |
| 6382 | - | ||
| 6383 | - | llamaConfigured := i.llamaConfigured | |
| 6384 | - | if i.cfg.LlamaCPPConfig != nil { | |
| 6385 | - | baseURL, _, err := i.cfg.LlamaCPPConfig() | |
| 6386 | - | llamaConfigured = err == nil && baseURL != "" | |
| 6387 | - | } | |
| 6388 | - | ||
| 6389 | - | i.mu.Lock() | |
| 6390 | - | i.llamaConfigured = llamaConfigured | |
| 6391 | - | if len(errs) > 0 { | |
| 6392 | - | i.statusErr = "logout errors: " + strings.Join(errs, "; ") | |
| 6393 | - | i.mu.Unlock() | |
| 6394 | - | return | |
| 6395 | - | } | |
| 6396 | - | i.statusErr = "" | |
| 6397 | - | var handoff json.RawMessage | |
| 6398 | - | var persistHandoff bool | |
| 6399 | - | if clearedCurrent { | |
| 6400 | - | // The running agent was using a credential we just wiped. Drop | |
| 6401 | - | // it so prompts can't go out with the stale client, and hint at | |
| 6402 | - | // /login. Close any LSP processes owned by its tools first. | |
| 6403 | - | if i.agent != nil { | |
| 6404 | - | _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot()) | |
| 6405 | - | } | |
| 6406 | - | i.agent = nil | |
| 6407 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 6408 | - | i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in." | |
| 6409 | - | } else { | |
| 6410 | - | i.statusOK = "logged out of " + strings.Join(providers, ", ") | |
| 6411 | - | } | |
| 6412 | - | i.mu.Unlock() | |
| 6413 | - | if persistHandoff { | |
| 6414 | - | i.persistCompactHandoff(handoff) | |
| 6415 | - | } | |
| 6416 | - | } | |
| 6417 | - | ||
| 6418 | - | func providerSetupInfo(provider string) (string, []string, bool) { | |
| 6419 | - | const docsURL = "https://raw.githubusercontent.com/bnema/zut/main/docs/providers.md" | |
| 6420 | - | switch provider { | |
| 6421 | - | case "amazon-bedrock": | |
| 6422 | - | return "Amazon Bedrock setup", []string{ | |
| 6423 | - | "Amazon Bedrock uses AWS credentials instead of a generic zut API-key entry.", | |
| 6424 | - | "Configure an AWS profile, IAM keys, bearer token, or role-based credentials.", | |
| 6425 | - | "", | |
| 6426 | - | "For Bedrock API keys, set:", | |
| 6427 | - | " AWS_BEARER_TOKEN_BEDROCK=...", | |
| 6428 | - | " AWS_REGION=us-east-1", | |
| 6429 | - | "", | |
| 6430 | - | "Docs:", | |
| 6431 | - | " " + docsURL, | |
| 6432 | - | }, true | |
| 6433 | - | case "google-vertex": | |
| 6434 | - | return "Google Vertex AI setup", []string{ | |
| 6435 | - | "Google Vertex AI usually uses Google Cloud credentials and project settings.", | |
| 6436 | - | "Set a Google API key, application-default credentials, or a service account.", | |
| 6437 | - | "", | |
| 6438 | - | "Common environment:", | |
| 6439 | - | " GOOGLE_CLOUD_API_KEY=...", | |
| 6440 | - | " GOOGLE_CLOUD_PROJECT=...", | |
| 6441 | - | " GOOGLE_CLOUD_LOCATION=us-central1", | |
| 6442 | - | "", | |
| 6443 | - | "Docs:", | |
| 6444 | - | " " + docsURL, | |
| 6445 | - | }, true | |
| 6446 | - | case "cloudflare-workers-ai": | |
| 6447 | - | return "Cloudflare Workers AI setup", []string{ | |
| 6448 | - | "Cloudflare Workers AI needs both an API token and an account ID.", | |
| 6449 | - | "", | |
| 6450 | - | "Set:", | |
| 6451 | - | " CLOUDFLARE_API_KEY=...", | |
| 6452 | - | " CLOUDFLARE_ACCOUNT_ID=...", | |
| 6453 | - | "", | |
| 6454 | - | "Docs:", | |
| 6455 | - | " " + docsURL, | |
| 6456 | - | }, true | |
| 6457 | - | case "cloudflare-ai-gateway": | |
| 6458 | - | return "Cloudflare AI Gateway setup", []string{ | |
| 6459 | - | "Cloudflare AI Gateway needs an API token, account ID, and gateway ID.", | |
| 6460 | - | "", | |
| 6461 | - | "Set:", | |
| 6462 | - | " CLOUDFLARE_API_KEY=...", | |
| 6463 | - | " CLOUDFLARE_ACCOUNT_ID=...", | |
| 6464 | - | " CLOUDFLARE_GATEWAY_ID=...", | |
| 6465 | - | "", | |
| 6466 | - | "Docs:", | |
| 6467 | - | " " + docsURL, | |
| 6468 | - | }, true | |
| 6469 | - | case "azure-openai-responses": | |
| 6470 | - | return "Azure OpenAI Responses setup", []string{ | |
| 6471 | - | "Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.", | |
| 6472 | - | "", | |
| 6473 | - | "Set:", | |
| 6474 | - | " AZURE_OPENAI_API_KEY=...", | |
| 6475 | - | " AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com", | |
| 6476 | - | " AZURE_OPENAI_API_VERSION=2024-02-01", | |
| 6477 | - | "", | |
| 6478 | - | "Docs:", | |
| 6479 | - | " " + docsURL, | |
| 6480 | - | }, true | |
| 6481 | - | default: | |
| 6482 | - | return "", nil, false | |
| 6483 | - | } | |
| 6484 | - | } | |
| 6485 | - | ||
| 6486 | - | func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) { | |
| 6487 | - | if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil { | |
| 6488 | - | i.dialog.ShowResult(false, "auth store is unavailable") | |
| 6489 | - | return | |
| 6490 | - | } | |
| 6491 | - | if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil { | |
| 6492 | - | i.dialog.ShowResult(false, err.Error()) | |
| 6493 | - | return | |
| 6494 | - | } | |
| 6495 | - | i.mu.Lock() | |
| 6496 | - | i.llamaConfigured = true | |
| 6497 | - | i.statusErr = "" | |
| 6498 | - | i.statusOK = "configured llama.cpp router" | |
| 6499 | - | i.mu.Unlock() | |
| 6500 | - | i.dialog.ShowResult(true, "") | |
| 6501 | - | } | |
| 6502 | - | ||
| 6503 | - | func (i *Interactive) startAPIKeyFlow(provider string) { | |
| 6504 | - | if title, lines, ok := providerSetupInfo(provider); ok { | |
| 6505 | - | i.dialog.ShowInfo(title, lines) | |
| 6506 | - | return | |
| 6507 | - | } | |
| 6508 | - | if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { | |
| 6509 | - | _ = i.cfg.SetKimiCLIFallbackDisabled(false) | |
| 6510 | - | } | |
| 6511 | - | url, err := i.cfg.AuthManager.StartAPIKey(provider) | |
| 6512 | - | if err != nil { | |
| 6513 | - | i.dialog.ShowResult(false, err.Error()) | |
| 6514 | - | return | |
| 6515 | - | } | |
| 6516 | - | i.dialog.ShowWaiting(url) | |
| 6517 | - | } | |
| 6518 | - | ||
| 6519 | - | func (i *Interactive) startOAuthFlow(provider string) { | |
| 6520 | - | if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { | |
| 6521 | - | _ = i.cfg.SetKimiCLIFallbackDisabled(false) | |
| 6522 | - | } | |
| 6523 | - | // Device-code providers already support headless login and must only | |
| 6524 | - | // start one polling flow. | |
| 6525 | - | if provider == "kimi" || provider == "xai" || provider == "github-copilot" { | |
| 6526 | - | loginURL, err := i.cfg.AuthManager.StartOAuth(provider) | |
| 6527 | - | if err != nil { | |
| 6528 | - | i.dialog.ShowResult(false, err.Error()) | |
| 6529 | - | return | |
| 6530 | - | } | |
| 6531 | - | i.dialog.ShowWaiting(loginURL) | |
| 6532 | - | return | |
| 6533 | - | } | |
| 6534 | - | // Always run the manual/copy-code flow in parallel with the local | |
| 6535 | - | // callback server so headless environments (docker, SSH) can paste | |
| 6536 | - | // the authorization code directly without first pressing 'p'. | |
| 6537 | - | _, err := i.cfg.AuthManager.StartOAuth(provider) | |
| 6538 | - | if err != nil { | |
| 6539 | - | i.dialog.ShowResult(false, err.Error()) | |
| 6540 | - | return | |
| 6541 | - | } | |
| 6542 | - | manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider) | |
| 6543 | - | if mErr == nil { | |
| 6544 | - | i.dialog.ShowWaiting(manualURL) | |
| 6545 | - | } else { | |
| 6546 | - | i.dialog.ShowResult(false, mErr.Error()) | |
| 6547 | - | } | |
| 6548 | - | } | |
| 6549 | - | ||
| 6550 | - | func (i *Interactive) startManualOAuthFlow(provider string) { | |
| 6551 | - | if i.cfg.AuthManager == nil { | |
| 6552 | - | return | |
| 6553 | - | } | |
| 6554 | - | i.cfg.AuthManager.CancelOAuth() | |
| 6555 | - | url, err := i.cfg.AuthManager.StartManualOAuth(provider) | |
| 6556 | - | if err != nil { | |
| 6557 | - | i.dialog.ShowResult(false, err.Error()) | |
| 6558 | - | return | |
| 6559 | - | } | |
| 6560 | - | i.dialog.url = url | |
| 6561 | - | i.invalidate() | |
| 6562 | - | } | |
| 6563 | - | ||
| 6564 | - | func (i *Interactive) submitManualOAuthCode(code string) { | |
| 6565 | - | if i.cfg.AuthManager == nil { | |
| 6566 | - | return | |
| 6567 | - | } | |
| 6568 | - | go func() { | |
| 6569 | - | if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil { | |
| 6570 | - | i.dialog.ShowResult(false, err.Error()) | |
| 6571 | - | i.invalidate() | |
| 6572 | - | } | |
| 6573 | - | }() | |
| 6574 | - | } | |
| 6575 | 1607 | ||
| 6576 | 1608 | // applyModelSelection switches the active model (and provider, if the | |
| 6577 | 1609 | // new model belongs to a different one). It rebuilds the underlying | |
| @@ -6585,541 +1617,59 @@ func (i *Interactive) submitManualOAuthCode(code string) { | |||
| 6585 | 1617 | // The wait is bounded; if the turn doesn't release within the timeout | |
| 6586 | 1618 | // we proceed anyway. Worst case is a brief overlap that the agent's | |
| 6587 | 1619 | // own mutex protects against. | |
| 6588 | - | func (i *Interactive) cancelAndWaitForIdle() { | |
| 6589 | - | i.mu.Lock() | |
| 6590 | - | busy := i.busy | |
| 6591 | - | cancel := i.cancelTurn | |
| 6592 | - | i.mu.Unlock() | |
| 6593 | - | if !busy { | |
| 6594 | - | return | |
| 6595 | - | } | |
| 6596 | - | if cancel != nil { | |
| 6597 | - | i.mu.Lock() | |
| 6598 | - | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 6599 | - | i.mu.Unlock() | |
| 6600 | - | cancel() | |
| 6601 | - | if persistHandoff { | |
| 6602 | - | i.persistCompactHandoff(handoff) | |
| 6603 | - | } | |
| 6604 | - | } | |
| 6605 | - | // ConfirmToolCall waits on its response channel rather than the turn | |
| 6606 | - | // context, so cancellation must also resolve every pending prompt. | |
| 6607 | - | i.confirmDialog.CancelAll("turn cancelled") | |
| 6608 | - | deadline := time.Now().Add(2 * time.Second) | |
| 6609 | - | for time.Now().Before(deadline) { | |
| 6610 | - | i.mu.Lock() | |
| 6611 | - | done := !i.busy | |
| 6612 | - | i.mu.Unlock() | |
| 6613 | - | if done { | |
| 6614 | - | return | |
| 6615 | - | } | |
| 6616 | - | time.Sleep(10 * time.Millisecond) | |
| 6617 | - | } | |
| 6618 | - | } | |
| 6619 | 1620 | ||
| 6620 | 1621 | // openBtwDialog opens the side-chat overlay with a frozen snapshot | |
| 6621 | 1622 | // of the current main session. The optional argument is auto- | |
| 6622 | 1623 | // submitted as the first question, so '/btw does X work?' fires the | |
| 6623 | 1624 | // model call immediately instead of just opening an empty dialog. | |
| 6624 | - | func (i *Interactive) openBtwDialog(args []string) { | |
| 6625 | - | i.agentMu.Lock() | |
| 6626 | - | defer i.agentMu.Unlock() | |
| 6627 | - | i.mu.Lock() | |
| 6628 | - | ag := i.agent | |
| 6629 | - | system := "" | |
| 6630 | - | if ag != nil { | |
| 6631 | - | system, _ = ag.PromptConfig() | |
| 6632 | - | } | |
| 6633 | - | theme := i.cfg.Theme | |
| 6634 | - | model := i.cfg.Model | |
| 6635 | - | cwd := i.cfg.CWD | |
| 6636 | - | compact := i.compactModeEnabled() | |
| 6637 | - | flatTools := i.cfg.FlatTools | |
| 6638 | - | lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines | |
| 6639 | - | i.mu.Unlock() | |
| 6640 | - | if ag == nil { | |
| 6641 | - | i.mu.Lock() | |
| 6642 | - | i.statusErr = "not logged in. type /login first." | |
| 6643 | - | i.mu.Unlock() | |
| 6644 | - | return | |
| 6645 | - | } | |
| 6646 | - | seed := strings.TrimSpace(strings.Join(args, " ")) | |
| 6647 | - | i.btwDialog.Open(theme, ag, system, model, cwd, seed, compact, flatTools, lineInput, i.invalidate) | |
| 6648 | - | i.invalidate() | |
| 6649 | - | } | |
| 6650 | 1625 | ||
| 6651 | 1626 | // submitOrQueuePrompt submits a slash command's expanded prompt immediately, | |
| 6652 | 1627 | // or queues it behind the active turn using the normal text-only queue. | |
| 6653 | - | func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) { | |
| 6654 | - | i.mu.Lock() | |
| 6655 | - | if i.agent == nil { | |
| 6656 | - | i.statusErr = "not logged in. type /login first." | |
| 6657 | - | i.statusOK = "" | |
| 6658 | - | i.mu.Unlock() | |
| 6659 | - | i.invalidate() | |
| 6660 | - | return | |
| 6661 | - | } | |
| 6662 | - | if i.busy { | |
| 6663 | - | // Keep the mutex held while enqueueing so the turn-completion | |
| 6664 | - | // goroutine cannot publish idle and inspect an empty queue between | |
| 6665 | - | // this check and the append. | |
| 6666 | - | var handoff json.RawMessage | |
| 6667 | - | var persistHandoff bool | |
| 6668 | - | if i.compacting { | |
| 6669 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 6670 | - | i.queued = append(i.queued, prompt) | |
| 6671 | - | } else { | |
| 6672 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 6673 | - | i.agent.QueueMessage(prompt) | |
| 6674 | - | } | |
| 6675 | - | i.mu.Unlock() | |
| 6676 | - | if persistHandoff { | |
| 6677 | - | i.persistCompactHandoff(handoff) | |
| 6678 | - | } | |
| 6679 | - | i.invalidate() | |
| 6680 | - | return | |
| 6681 | - | } | |
| 6682 | - | i.mu.Unlock() | |
| 6683 | - | i.startTurn(ctx, prompt) | |
| 6684 | - | } | |
| 6685 | 1628 | ||
| 6686 | 1629 | // openSkillsDialog opens the skill inspector. The picker reflects | |
| 6687 | 1630 | // whatever SkillSnapshot returns at call time, so edits to a | |
| 6688 | 1631 | // SKILL.md made during a session show up on the next /skills. | |
| 6689 | - | func (i *Interactive) openSkillsDialog() { | |
| 6690 | - | var list []*skills.Skill | |
| 6691 | - | if i.cfg.SkillSnapshot != nil { | |
| 6692 | - | list = i.cfg.SkillSnapshot() | |
| 6693 | - | } | |
| 6694 | - | i.skillsDialog.Open(list) | |
| 6695 | - | i.invalidate() | |
| 6696 | - | } | |
| 6697 | 1632 | ||
| 6698 | 1633 | // openJumpDialog builds a /jump picker from the current transcript. | |
| 6699 | 1634 | // If the user typed "/jump foo" with a filter and it matches exactly | |
| 6700 | 1635 | // one turn, jump there directly without showing the dialog. | |
| 6701 | - | func (i *Interactive) openJumpDialog(args []string) { | |
| 6702 | - | if i.view == nil || len(i.view.Messages) == 0 { | |
| 6703 | - | i.mu.Lock() | |
| 6704 | - | i.statusErr = "nothing to jump to \u2014 the session is empty" | |
| 6705 | - | i.mu.Unlock() | |
| 6706 | - | return | |
| 6707 | - | } | |
| 6708 | - | filter := strings.TrimSpace(strings.Join(args, " ")) | |
| 6709 | - | i.jumpDialog.Open(i.view.Messages, filter) | |
| 6710 | - | // Shortcut: with a filter argument that matches exactly one turn, | |
| 6711 | - | // jump immediately and skip the picker. | |
| 6712 | - | if filter != "" { | |
| 6713 | - | if tgts := i.jumpDialog.Targets(); len(tgts) == 1 { | |
| 6714 | - | t := tgts[0] | |
| 6715 | - | i.jumpDialog.Close() | |
| 6716 | - | i.applyJumpSelection(t.MessageIdx, t.TurnNo) | |
| 6717 | - | } | |
| 6718 | - | } | |
| 6719 | - | } | |
| 6720 | - | ||
| 6721 | - | // applyJumpSelection scrolls the chat viewport so the user message at | |
| 6722 | - | // msgIdx is visible at (or near) the top of the chat area. Uses the | |
| 6723 | - | // anchor slice returned by view.BuildWithAnchors so the mapping from | |
| 6724 | - | // message index to row is exact, regardless of variable-height tool | |
| 6725 | - | // blocks above the target. | |
| 6726 | - | func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) { | |
| 6727 | - | cols := i.lastCols() | |
| 6728 | - | chat, anchors := i.view.BuildWithAnchors(cols) | |
| 6729 | - | var row int | |
| 6730 | - | found := false | |
| 6731 | - | for _, a := range anchors { | |
| 6732 | - | if a.MessageIdx == msgIdx { | |
| 6733 | - | row = a.Row | |
| 6734 | - | found = true | |
| 6735 | - | break | |
| 6736 | - | } | |
| 6737 | - | } | |
| 6738 | - | if !found { | |
| 6739 | - | i.mu.Lock() | |
| 6740 | - | i.statusErr = "could not resolve jump target" | |
| 6741 | - | i.mu.Unlock() | |
| 6742 | - | return | |
| 6743 | - | } | |
| 6744 | 1636 | ||
| 6745 | - | chatLen := len(chat) | |
| 6746 | - | page := i.chatPage() | |
| 6747 | - | if page < 1 { | |
| 6748 | - | page = 1 | |
| 6749 | - | } | |
| 6750 | - | // scrollOffset is measured from the bottom of the chat slice, so | |
| 6751 | - | // to place `row` at the top of the viewport we want: | |
| 6752 | - | // chatLen - scrollOffset - page == row | |
| 6753 | - | // Solve for scrollOffset and clamp to [0, chatLen-page]. | |
| 6754 | - | offset := chatLen - (row + page) | |
| 6755 | - | if offset < 0 { | |
| 6756 | - | offset = 0 | |
| 6757 | - | } | |
| 6758 | - | maxOffset := chatLen - page | |
| 6759 | - | if maxOffset < 0 { | |
| 6760 | - | maxOffset = 0 | |
| 6761 | - | } | |
| 6762 | - | if offset > maxOffset { | |
| 6763 | - | offset = maxOffset | |
| 6764 | - | } | |
| 6765 | - | ||
| 6766 | - | i.mu.Lock() | |
| 6767 | - | i.scrollOffset = offset | |
| 6768 | - | i.parkedTurn = turnNo | |
| 6769 | - | i.parkedTotal = totalTurnsLocked(i.view.Messages) | |
| 6770 | - | i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo) | |
| 6771 | - | i.statusErr = "" | |
| 6772 | - | i.mu.Unlock() | |
| 6773 | - | } | |
| 6774 | - | ||
| 6775 | - | // totalTurnsLocked counts user messages in the transcript. Caller is | |
| 6776 | - | // assumed to hold i.mu (the name is a mild reminder; this function | |
| 6777 | - | // itself doesn't touch shared state beyond the slice it's handed). | |
| 6778 | - | func totalTurnsLocked(msgs []provider.Message) int { | |
| 6779 | - | n := 0 | |
| 6780 | - | for _, m := range msgs { | |
| 6781 | - | if m.Role == provider.RoleUser { | |
| 6782 | - | n++ | |
| 6783 | - | } | |
| 6784 | - | } | |
| 6785 | - | return n | |
| 6786 | - | } | |
| 6787 | - | ||
| 6788 | - | // applySessionSelection loads the given session via the cli-provided | |
| 6789 | - | // callback and snaps the viewport to the bottom (the latest message) | |
| 6790 | - | // so the user lands at the live tail of the resumed conversation. | |
| 6791 | - | func (i *Interactive) applySessionSelection(path string) { | |
| 6792 | - | if i.cfg.LoadSession == nil { | |
| 6793 | - | i.mu.Lock() | |
| 6794 | - | i.statusErr = "session loading is not wired in this build" | |
| 6795 | - | i.mu.Unlock() | |
| 6796 | - | return | |
| 6797 | - | } | |
| 6798 | - | i.mu.Lock() | |
| 6799 | - | if i.sessionLoading { | |
| 6800 | - | i.statusErr = "already resuming a session" | |
| 6801 | - | i.mu.Unlock() | |
| 6802 | - | i.invalidate() | |
| 6803 | - | return | |
| 6804 | - | } | |
| 6805 | - | i.sessionLoading = true | |
| 6806 | - | i.statusOK = "resuming session: " + path | |
| 6807 | - | i.statusErr = "" | |
| 6808 | - | i.mu.Unlock() | |
| 6809 | - | i.invalidate() | |
| 6810 | - | i.markSessionTitleSwitching() | |
| 6811 | - | ||
| 6812 | - | go func() { | |
| 6813 | - | err := i.cfg.LoadSession(path) | |
| 6814 | - | if err != nil { | |
| 6815 | - | i.restoreFailedSessionTitle() | |
| 6816 | - | i.mu.Lock() | |
| 6817 | - | i.sessionLoading = false | |
| 6818 | - | i.statusErr = err.Error() | |
| 6819 | - | i.statusOK = "" | |
| 6820 | - | i.mu.Unlock() | |
| 6821 | - | i.invalidate() | |
| 6822 | - | return | |
| 6823 | - | } | |
| 6824 | - | i.restoreLoadedSessionTitle() | |
| 6825 | - | state := decodeCompactHandoff(i.currentCompactHandoff()) | |
| 6826 | - | i.mu.Lock() | |
| 6827 | - | i.compactContinuation = state | |
| 6828 | - | i.sessionLoading = false | |
| 6829 | - | i.statusOK = "resumed session: " + path | |
| 6830 | - | i.statusErr = "" | |
| 6831 | - | i.parkedTurn = 0 | |
| 6832 | - | i.parkedTotal = 0 | |
| 6833 | - | i.scrollOffset = 0 | |
| 6834 | - | // Fresh transcript swapped in: drop the auto-follow baseline so | |
| 6835 | - | // the next render's follow guard doesn't see the wholesale | |
| 6836 | - | // length change as a delta and jump the viewport. | |
| 6837 | - | i.prevChatLen = 0 | |
| 6838 | - | i.prevChatCols = 0 | |
| 6839 | - | i.extNotes = nil | |
| 6840 | - | i.view.InvalidateRenderCache() | |
| 6841 | - | if i.agent != nil { | |
| 6842 | - | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |
| 6843 | - | i.cumUsage = i.agent.Cost() | |
| 6844 | - | if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 { | |
| 6845 | - | i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens | |
| 6846 | - | } else { | |
| 6847 | - | i.lastCtxInput = 0 | |
| 6848 | - | } | |
| 6849 | - | // Snap to the tail again — the swap brought in a fresh | |
| 6850 | - | // transcript whose markdown / chroma cost we don't want | |
| 6851 | - | // blocking the redraw. | |
| 6852 | - | if len(i.view.Messages) > initialResumeTailLimit { | |
| 6853 | - | i.view.TailLimit = initialResumeTailLimit | |
| 6854 | - | } else { | |
| 6855 | - | i.view.TailLimit = 0 | |
| 6856 | - | } | |
| 6857 | - | } | |
| 6858 | - | i.mu.Unlock() | |
| 6859 | - | i.invalidate() | |
| 6860 | - | if state.reason != compactContinuationNone { | |
| 6861 | - | i.startRestoredCompactHandoff(i.runCtx) | |
| 6862 | - | } | |
| 6863 | - | }() | |
| 6864 | - | } | |
| 6865 | - | ||
| 6866 | - | func (i *Interactive) applyModelSelection(prov, model string) { | |
| 6867 | - | i.swapModel(prov, model, i.cfg.BuildAgentFor, false) | |
| 6868 | - | } | |
| 6869 | - | ||
| 6870 | - | // applyRescueModelSelection is like applyModelSelection but routes | |
| 6871 | - | // through BuildAgentForRescue so launch-time --api-key / --base-url | |
| 6872 | - | // overrides are dropped before the new agent is built. Falls back to | |
| 6873 | - | // the regular builder when the host doesn't wire a rescue builder. | |
| 6874 | - | func (i *Interactive) applyRescueModelSelection(prov, model string) { | |
| 6875 | - | builder := i.cfg.BuildAgentForRescue | |
| 6876 | - | if builder == nil { | |
| 6877 | - | builder = i.cfg.BuildAgentFor | |
| 6878 | - | } | |
| 6879 | - | i.swapModel(prov, model, builder, true) | |
| 6880 | - | } | |
| 6881 | - | ||
| 6882 | - | // swapModel applies a /model selection (or a rescue selection) using | |
| 6883 | - | // the supplied builder. rescue=true tags the success message so the | |
| 6884 | - | // user can see that launch-time overrides were ignored. | |
| 6885 | - | func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) { | |
| 6886 | - | var replaced bool | |
| 6887 | - | swap := func() { | |
| 6888 | - | replaced = i.swapModelUnserialized(prov, model, builder, rescue) | |
| 6889 | - | } | |
| 6890 | - | if i.cfg.SessionTransition != nil { | |
| 6891 | - | i.cfg.SessionTransition(swap) | |
| 6892 | - | } else { | |
| 6893 | - | swap() | |
| 6894 | - | } | |
| 6895 | - | if replaced { | |
| 6896 | - | i.resetCompactHandoff() | |
| 6897 | - | } | |
| 6898 | - | } | |
| 6899 | - | ||
| 6900 | - | func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) bool { | |
| 6901 | - | if model == "" { | |
| 6902 | - | return false | |
| 6903 | - | } | |
| 6904 | - | m, err := provider.FindModel(prov, model) | |
| 6905 | - | if err != nil { | |
| 6906 | - | i.mu.Lock() | |
| 6907 | - | i.statusErr = err.Error() | |
| 6908 | - | i.mu.Unlock() | |
| 6909 | - | return false | |
| 6910 | - | } | |
| 6911 | - | // Same provider AND not a rescue retry: just swap the model on | |
| 6912 | - | // the existing agent. Mixed-API providers dispatch from model metadata, | |
| 6913 | - | // so the client remains reusable. Rescue retries always rebuild so a stale | |
| 6914 | - | // auth header / base URL can't carry over. | |
| 6915 | - | if !rescue && i.agent != nil && m.Provider == i.cfg.Provider { | |
| 6916 | - | i.mu.Lock() | |
| 6917 | - | i.cfg.Model = m.ID | |
| 6918 | - | i.agent.Model = m.ID | |
| 6919 | - | i.statusOK = "model: " + m.ID | |
| 6920 | - | i.statusErr = "" | |
| 6921 | - | i.mu.Unlock() | |
| 6922 | - | if i.cfg.PersistModel != nil { | |
| 6923 | - | i.cfg.PersistModel(i.cfg.Provider, m.ID) | |
| 6924 | - | } | |
| 6925 | - | return false | |
| 6926 | - | } | |
| 6927 | - | if builder == nil { | |
| 6928 | - | i.mu.Lock() | |
| 6929 | - | i.statusErr = "cannot switch provider: no builder configured" | |
| 6930 | - | i.mu.Unlock() | |
| 6931 | - | return false | |
| 6932 | - | } | |
| 6933 | - | // Snapshot the current transcript and cumulative usage BEFORE we | |
| 6934 | - | // build the replacement agent so we can hand them off. Without | |
| 6935 | - | // this the user perceives the entire session as wiped on a | |
| 6936 | - | // cross-provider /model swap. | |
| 6937 | - | var carryMsgs []provider.Message | |
| 6938 | - | var carryCost provider.Usage | |
| 6939 | - | if i.agent != nil { | |
| 6940 | - | carryMsgs = i.agent.Messages() | |
| 6941 | - | carryCost = i.agent.Cost() | |
| 6942 | - | } | |
| 1637 | + | // applyJumpSelection scrolls the chat viewport so the user message at | |
| 1638 | + | // msgIdx is visible at (or near) the top of the chat area. Uses the | |
| 1639 | + | // anchor slice returned by view.BuildWithAnchors so the mapping from | |
| 1640 | + | // message index to row is exact, regardless of variable-height tool | |
| 1641 | + | // blocks above the target. | |
| 6943 | 1642 | ||
| 6944 | - | ag, p, md, err := builder(m.Provider, m.ID) | |
| 6945 | - | if err != nil { | |
| 6946 | - | i.mu.Lock() | |
| 6947 | - | i.statusErr = err.Error() | |
| 6948 | - | i.mu.Unlock() | |
| 6949 | - | return false | |
| 6950 | - | } | |
| 1643 | + | // totalTurnsLocked counts user messages in the transcript. Caller is | |
| 1644 | + | // assumed to hold i.mu (the name is a mild reminder; this function | |
| 1645 | + | // itself doesn't touch shared state beyond the slice it's handed). | |
| 6951 | 1646 | ||
| 6952 | - | // Replay the transcript and seed the cost on the freshly-built | |
| 6953 | - | // agent. Messages travel cleanly between providers because they | |
| 6954 | - | // use the same provider.Message shape; tool-call ids are local | |
| 6955 | - | // to a turn so cross-provider continuation never confuses the | |
| 6956 | - | // new model (it just sees the assistant's reply, no orphan | |
| 6957 | - | // tool_use blocks because /model swaps are gated to idle state). | |
| 6958 | - | if len(carryMsgs) > 0 { | |
| 6959 | - | ag.SetMessages(carryMsgs) | |
| 6960 | - | } | |
| 6961 | - | ag.SeedCost(carryCost) | |
| 1647 | + | // applySessionSelection loads the given session via the cli-provided | |
| 1648 | + | // callback and snaps the viewport to the bottom (the latest message) | |
| 1649 | + | // so the user lands at the live tail of the resumed conversation. | |
| 6962 | 1650 | ||
| 6963 | - | if i.agent != nil && i.agent != ag { | |
| 6964 | - | _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot()) | |
| 6965 | - | } | |
| 6966 | - | i.agentMu.Lock() | |
| 6967 | - | i.mu.Lock() | |
| 6968 | - | i.prepareReplacementAgentLocked(ag) | |
| 6969 | - | i.agent = ag | |
| 6970 | - | i.cfg.Provider = p | |
| 6971 | - | i.cfg.Model = md | |
| 6972 | - | if rescue { | |
| 6973 | - | i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)" | |
| 6974 | - | } else { | |
| 6975 | - | i.statusOK = "switched to " + p + " / " + md | |
| 6976 | - | } | |
| 6977 | - | i.statusErr = "" | |
| 6978 | - | // Render cache keys are width+content based, so the new agent's | |
| 6979 | - | // identical messages will reuse the existing entries. Nothing | |
| 6980 | - | // to invalidate. | |
| 6981 | - | i.mu.Unlock() | |
| 6982 | - | i.agentMu.Unlock() | |
| 6983 | - | // The new agent was built off the base tool registry, so any | |
| 6984 | - | // dynamically-registered tools need to be reattached. The apply | |
| 6985 | - | // helpers are no-ops when their feature is inactive, so the | |
| 6986 | - | // cross-provider path still works on a vanilla setup. | |
| 6987 | - | i.applyAutoSubagentsTool() | |
| 6988 | - | i.applyTelegramTools(i.telegramBridge != nil) | |
| 6989 | - | if i.cfg.PersistModel != nil { | |
| 6990 | - | i.cfg.PersistModel(p, md) | |
| 6991 | - | } | |
| 6992 | - | return true | |
| 6993 | - | } | |
| 1651 | + | // applyRescueModelSelection is like applyModelSelection but routes | |
| 1652 | + | // through BuildAgentForRescue so launch-time --api-key / --base-url | |
| 1653 | + | // overrides are dropped before the new agent is built. Falls back to | |
| 1654 | + | // the regular builder when the host doesn't wire a rescue builder. | |
| 6994 | 1655 | ||
| 6995 | - | func (i *Interactive) handleAuthEvent(ev auth.Event) { | |
| 6996 | - | switch ev.Kind { | |
| 6997 | - | case "started": | |
| 6998 | - | i.dialog.ShowWaiting(ev.URL) | |
| 6999 | - | case "browser_open": | |
| 7000 | - | // no-op | |
| 7001 | - | case "error": | |
| 7002 | - | i.dialog.ShowResult(false, ev.Message) | |
| 7003 | - | case "success": | |
| 7004 | - | // Keep the credential-driven resolve and its final replacement in the | |
| 7005 | - | // same transition as settings/session changes. Otherwise a build that | |
| 7006 | - | // started under the old web-search policy could commit afterward. | |
| 7007 | - | var buildErr error | |
| 7008 | - | var clearHandoff bool | |
| 7009 | - | buildAndCommitLogin := func() { | |
| 7010 | - | ag, prov, model, err := i.cfg.BuildAgent() | |
| 7011 | - | if err != nil { | |
| 7012 | - | buildErr = err | |
| 7013 | - | return | |
| 7014 | - | } | |
| 7015 | - | i.agentMu.Lock() | |
| 7016 | - | i.mu.Lock() | |
| 7017 | - | oldAgent := i.agent | |
| 7018 | - | i.prepareReplacementAgentLocked(ag) | |
| 7019 | - | _, clearHandoff = i.resetCompactContinuationLocked() | |
| 7020 | - | i.agent = ag | |
| 7021 | - | i.cfg.Provider = prov | |
| 7022 | - | i.cfg.Model = model | |
| 7023 | - | i.statusErr = "" | |
| 7024 | - | i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method | |
| 7025 | - | i.mu.Unlock() | |
| 7026 | - | i.agentMu.Unlock() | |
| 7027 | - | if oldAgent != nil { | |
| 7028 | - | _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot()) | |
| 7029 | - | } | |
| 7030 | - | i.applyAutoSubagentsTool() | |
| 7031 | - | i.applyTelegramTools(i.telegramBridge != nil) | |
| 7032 | - | // Authentication can change the provider used by the live | |
| 7033 | - | // agent. Persist it on the active session just like /model. | |
| 7034 | - | if i.cfg.PersistModel != nil { | |
| 7035 | - | i.cfg.PersistModel(prov, model) | |
| 7036 | - | } | |
| 7037 | - | } | |
| 7038 | - | if i.cfg.SessionTransition != nil { | |
| 7039 | - | i.cfg.SessionTransition(buildAndCommitLogin) | |
| 7040 | - | } else { | |
| 7041 | - | buildAndCommitLogin() | |
| 7042 | - | } | |
| 7043 | - | if buildErr != nil { | |
| 7044 | - | i.dialog.ShowResult(false, buildErr.Error()) | |
| 7045 | - | return | |
| 7046 | - | } | |
| 7047 | - | if clearHandoff { | |
| 7048 | - | i.persistCompactHandoff(nil) | |
| 7049 | - | } | |
| 7050 | - | i.dialog.ShowResult(true, "") | |
| 7051 | - | } | |
| 7052 | - | } | |
| 1656 | + | // swapModel applies a /model selection (or a rescue selection) using | |
| 1657 | + | // the supplied builder. rescue=true tags the success message so the | |
| 1658 | + | // user can see that launch-time overrides were ignored. | |
| 7053 | 1659 | ||
| 7054 | 1660 | // clearPendingCompactTurnLocked drops work that only makes sense after the | |
| 7055 | 1661 | // current compaction. The caller must hold i.mu. | |
| 7056 | - | func (i *Interactive) clearPendingCompactTurnLocked() { | |
| 7057 | - | i.pendingCompactPrompt = "" | |
| 7058 | - | i.pendingCompactImages = nil | |
| 7059 | - | i.hasPendingCompactPrompt = false | |
| 7060 | - | i.continueAfterCompact = false | |
| 7061 | - | i.pendingPostCompactNote = "" | |
| 7062 | - | } | |
| 7063 | 1662 | ||
| 7064 | 1663 | // compactHandoffLocked returns a self-contained persistence snapshot. The | |
| 7065 | 1664 | // caller must hold i.mu. | |
| 7066 | - | func (i *Interactive) compactHandoffLocked() json.RawMessage { | |
| 7067 | - | return encodeCompactHandoff(i.compactContinuation) | |
| 7068 | - | } | |
| 7069 | 1665 | ||
| 7070 | 1666 | // setCompactContinuationLocked updates the private handoff state and returns | |
| 7071 | 1667 | // its persistence snapshot plus whether persistence is needed. The caller must | |
| 7072 | 1668 | // hold i.mu. | |
| 7073 | - | func (i *Interactive) setCompactContinuationLocked(state compactContinuationState) (json.RawMessage, bool) { | |
| 7074 | - | if i.compactContinuation == state { | |
| 7075 | - | return i.compactHandoffLocked(), false | |
| 7076 | - | } | |
| 7077 | - | i.compactContinuation = state | |
| 7078 | - | return i.compactHandoffLocked(), true | |
| 7079 | - | } | |
| 7080 | 1669 | ||
| 7081 | 1670 | // resetCompactContinuationLocked clears the private handoff state and returns | |
| 7082 | 1671 | // its persistence snapshot plus whether persistence is needed. The caller must | |
| 7083 | 1672 | // hold i.mu. | |
| 7084 | - | func (i *Interactive) resetCompactContinuationLocked() (json.RawMessage, bool) { | |
| 7085 | - | return i.setCompactContinuationLocked(compactContinuationState{}) | |
| 7086 | - | } | |
| 7087 | - | ||
| 7088 | - | func (i *Interactive) persistCompactHandoff(state json.RawMessage) { | |
| 7089 | - | if persist := i.cfg.PersistCompactHandoff; persist != nil { | |
| 7090 | - | if err := persist(state); err != nil { | |
| 7091 | - | i.ReportError(fmt.Errorf("persist compact handoff: %w", err)) | |
| 7092 | - | } | |
| 7093 | - | } | |
| 7094 | - | } | |
| 7095 | - | ||
| 7096 | - | func (i *Interactive) resetCompactHandoff() { | |
| 7097 | - | i.mu.Lock() | |
| 7098 | - | state, persist := i.resetCompactContinuationLocked() | |
| 7099 | - | i.mu.Unlock() | |
| 7100 | - | if persist { | |
| 7101 | - | i.persistCompactHandoff(state) | |
| 7102 | - | } | |
| 7103 | - | } | |
| 7104 | - | ||
| 7105 | - | func (i *Interactive) currentCompactHandoff() json.RawMessage { | |
| 7106 | - | if current := i.cfg.CurrentCompactHandoff; current != nil { | |
| 7107 | - | return current() | |
| 7108 | - | } | |
| 7109 | - | return nil | |
| 7110 | - | } | |
| 7111 | - | ||
| 7112 | - | func (i *Interactive) restoreCompactHandoff(state compactContinuationState) { | |
| 7113 | - | i.mu.Lock() | |
| 7114 | - | i.compactContinuation = state | |
| 7115 | - | i.mu.Unlock() | |
| 7116 | - | } | |
| 7117 | - | ||
| 7118 | - | func (i *Interactive) restoreCurrentCompactHandoff() compactContinuationState { | |
| 7119 | - | state := decodeCompactHandoff(i.currentCompactHandoff()) | |
| 7120 | - | i.restoreCompactHandoff(state) | |
| 7121 | - | return state | |
| 7122 | - | } | |
| 7123 | 1673 | ||
| 7124 | 1674 | // autoCompactContinuationPrompt is an interactive-only compact-handoff | |
| 7125 | 1675 | // instruction. Its completion criteria must stay separate from Ponytail so | |
| @@ -7139,147 +1689,16 @@ const ( | |||
| 7139 | 1689 | // explicit user message supersedes the checkpoint; tool activity remains an | |
| 7140 | 1690 | // unfinished existing turn. No hidden prompt means the process stopped after | |
| 7141 | 1691 | // recording the handoff but before appending its context message. | |
| 7142 | - | func classifyCompactHandoffResume(messages []provider.Message) compactHandoffResume { | |
| 7143 | - | lastHandoff := -1 | |
| 7144 | - | for idx := len(messages) - 1; idx >= 0; idx-- { | |
| 7145 | - | message := messages[idx] | |
| 7146 | - | if message.Role == provider.RoleUser && message.Meta[autoCompactContinueMetaKey] == "true" { | |
| 7147 | - | lastHandoff = idx | |
| 7148 | - | break | |
| 7149 | - | } | |
| 7150 | - | } | |
| 7151 | - | if lastHandoff < 0 { | |
| 7152 | - | return compactHandoffAppendPrompt | |
| 7153 | - | } | |
| 7154 | - | for _, message := range messages[lastHandoff+1:] { | |
| 7155 | - | switch message.Role { | |
| 7156 | - | case provider.RoleTool: | |
| 7157 | - | continue | |
| 7158 | - | case provider.RoleAssistant: | |
| 7159 | - | hasToolCall := false | |
| 7160 | - | for _, content := range message.Content { | |
| 7161 | - | if _, ok := content.(provider.ToolCallBlock); ok { | |
| 7162 | - | hasToolCall = true | |
| 7163 | - | break | |
| 7164 | - | } | |
| 7165 | - | } | |
| 7166 | - | if !hasToolCall { | |
| 7167 | - | return compactHandoffDiscard | |
| 7168 | - | } | |
| 7169 | - | default: | |
| 7170 | - | return compactHandoffDiscard | |
| 7171 | - | } | |
| 7172 | - | } | |
| 7173 | - | return compactHandoffContinueExisting | |
| 7174 | - | } | |
| 7175 | 1692 | ||
| 7176 | 1693 | // startRestoredCompactHandoff resumes a handoff checkpoint restored from the | |
| 7177 | 1694 | // active session. When its hidden context message was already persisted, the | |
| 7178 | 1695 | // agent continues that existing turn rather than appending it again. | |
| 7179 | - | func (i *Interactive) startRestoredCompactHandoff(parent context.Context) { | |
| 7180 | - | if parent == nil { | |
| 7181 | - | parent = i.runCtx | |
| 7182 | - | } | |
| 7183 | - | if parent == nil { | |
| 7184 | - | parent = context.Background() | |
| 7185 | - | } | |
| 7186 | - | i.mu.Lock() | |
| 7187 | - | state := i.compactContinuation | |
| 7188 | - | ag := i.agent | |
| 7189 | - | busy := i.busy || i.compacting || i.autoCompacting || i.shellRunning | |
| 7190 | - | if state.reason == compactContinuationNone || ag == nil || busy { | |
| 7191 | - | i.mu.Unlock() | |
| 7192 | - | return | |
| 7193 | - | } | |
| 7194 | - | ||
| 7195 | - | var next string | |
| 7196 | - | continueQueued := false | |
| 7197 | - | var resume compactHandoffResume | |
| 7198 | - | var handoff json.RawMessage | |
| 7199 | - | var persistHandoff bool | |
| 7200 | - | switch { | |
| 7201 | - | case state.reason != compactContinuationForcedLength && len(i.queued) > 0: | |
| 7202 | - | next, i.queued = i.queued[0], i.queued[1:] | |
| 7203 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7204 | - | case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0: | |
| 7205 | - | continueQueued = true | |
| 7206 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7207 | - | default: | |
| 7208 | - | resume = classifyCompactHandoffResume(ag.Messages()) | |
| 7209 | - | if resume == compactHandoffDiscard { | |
| 7210 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7211 | - | } | |
| 7212 | - | } | |
| 7213 | - | // Reserve the turn slot before persisting or dispatching so concurrent input | |
| 7214 | - | // can only queue behind the restored handoff, never start a competing turn. | |
| 7215 | - | starting := next != "" || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt | |
| 7216 | - | i.busy = starting | |
| 7217 | - | i.mu.Unlock() | |
| 7218 | - | if persistHandoff { | |
| 7219 | - | i.persistCompactHandoff(handoff) | |
| 7220 | - | } | |
| 7221 | - | if !starting { | |
| 7222 | - | i.invalidate() | |
| 7223 | - | return | |
| 7224 | - | } | |
| 7225 | - | switch { | |
| 7226 | - | case next != "": | |
| 7227 | - | i.startTurn(parent, next) | |
| 7228 | - | case continueQueued: | |
| 7229 | - | i.startTurnRequest(parent, "", nil, true, false) | |
| 7230 | - | case resume == compactHandoffContinueExisting: | |
| 7231 | - | i.startTurnRequest(parent, "", nil, true, false) | |
| 7232 | - | case resume == compactHandoffAppendPrompt: | |
| 7233 | - | i.startAutoCompactContinuation(parent) | |
| 7234 | - | } | |
| 7235 | - | } | |
| 7236 | 1696 | ||
| 7237 | 1697 | // startAutoCompactContinuation adds an internal user turn that tells the | |
| 7238 | 1698 | // model to resume the task after threshold compaction. Provider requests | |
| 7239 | 1699 | // cannot continue from an assistant tail on every supported API, so the | |
| 7240 | 1700 | // continuation must be represented as a user message. It is hidden from | |
| 7241 | 1701 | // the rendered transcript but remains in the persisted/model context. | |
| 7242 | - | func (i *Interactive) startAutoCompactContinuation(parent context.Context) { | |
| 7243 | - | i.mu.Lock() | |
| 7244 | - | ag := i.agent | |
| 7245 | - | if ag == nil { | |
| 7246 | - | i.busy = false | |
| 7247 | - | pendingIdleWork := i.takePendingIdleWorkLocked() | |
| 7248 | - | i.mu.Unlock() | |
| 7249 | - | runPendingIdleWork(pendingIdleWork) | |
| 7250 | - | i.invalidate() | |
| 7251 | - | return | |
| 7252 | - | } | |
| 7253 | - | // The caller selected the continuation while holding i.mu. Keep that | |
| 7254 | - | // ordering through the hidden append so an explicitly queued user prompt | |
| 7255 | - | // cannot be overtaken by an old-task handoff. Forced truncation is the | |
| 7256 | - | // existing exception: it keeps priority and the queued prompt runs after it. | |
| 7257 | - | if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 { | |
| 7258 | - | next := i.queued[0] | |
| 7259 | - | i.queued = i.queued[1:] | |
| 7260 | - | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 7261 | - | i.mu.Unlock() | |
| 7262 | - | if persistHandoff { | |
| 7263 | - | i.persistCompactHandoff(handoff) | |
| 7264 | - | } | |
| 7265 | - | i.startTurn(parent, next) | |
| 7266 | - | return | |
| 7267 | - | } | |
| 7268 | - | if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 { | |
| 7269 | - | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 7270 | - | i.mu.Unlock() | |
| 7271 | - | if persistHandoff { | |
| 7272 | - | i.persistCompactHandoff(handoff) | |
| 7273 | - | } | |
| 7274 | - | i.startTurnRequest(parent, "", nil, true, false) | |
| 7275 | - | return | |
| 7276 | - | } | |
| 7277 | - | ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{ | |
| 7278 | - | autoCompactContinueMetaKey: "true", | |
| 7279 | - | }) | |
| 7280 | - | i.mu.Unlock() | |
| 7281 | - | i.startTurnRequest(parent, "", nil, true, false) | |
| 7282 | - | } | |
| 7283 | 1702 | ||
| 7284 | 1703 | // runCompact invokes core.Agent.Compact and reflects the progress in | |
| 7285 | 1704 | // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c | |
| @@ -7290,222 +1709,12 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) { | |||
| 7290 | 1709 | // percentage so it's obvious the system triggered this, not the user. | |
| 7291 | 1710 | // request.force tells the post-compact hand-off to resume even if the | |
| 7292 | 1711 | // transcript tail has visible assistant text, for example after StopLength. | |
| 7293 | - | func (i *Interactive) runCompact(parent context.Context, request compactContinuationRequest) { | |
| 7294 | - | auto := request.origin != compactOriginManual | |
| 7295 | - | if i.agent == nil { | |
| 7296 | - | i.mu.Lock() | |
| 7297 | - | i.statusErr = "not logged in. type /login first." | |
| 7298 | - | i.mu.Unlock() | |
| 7299 | - | return | |
| 7300 | - | } | |
| 7301 | - | ctx, cancel := context.WithCancel(parent) | |
| 7302 | - | i.mu.Lock() | |
| 7303 | - | i.busy = true | |
| 7304 | - | i.compacting = true | |
| 7305 | - | i.spin.Start() | |
| 7306 | - | i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model) | |
| 7307 | - | if auto { | |
| 7308 | - | i.activity.kind = activityCondensingHistory | |
| 7309 | - | i.autoCompacting = true | |
| 7310 | - | } else { | |
| 7311 | - | i.activity.kind = activityCompactingHistory | |
| 7312 | - | } | |
| 7313 | - | i.cancelTurn = cancel | |
| 7314 | - | var initialHandoff json.RawMessage | |
| 7315 | - | persistInitialHandoff := false | |
| 7316 | - | if request.origin == compactOriginManual || request.origin == compactOriginRecovery { | |
| 7317 | - | initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked() | |
| 7318 | - | } | |
| 7319 | - | i.statusErr = "" | |
| 7320 | - | i.statusOK = "" | |
| 7321 | - | // Do NOT set streamOn: the summary text should not be visible | |
| 7322 | - | // in the chat while compacting. The user just sees the spinner | |
| 7323 | - | // and can keep typing / queue prompts. | |
| 7324 | - | i.scrollOffset = 0 | |
| 7325 | - | i.helpBlock = nil | |
| 7326 | - | i.mu.Unlock() | |
| 7327 | - | if persistInitialHandoff { | |
| 7328 | - | i.persistCompactHandoff(initialHandoff) | |
| 7329 | - | } | |
| 7330 | - | i.invalidate() | |
| 7331 | - | ||
| 7332 | - | go func() { | |
| 7333 | - | // Sink discards deltas — we don't stream the summary to the UI. | |
| 7334 | - | sink := func(delta string) {} | |
| 7335 | - | msgsBefore := i.agent.Messages() | |
| 7336 | - | i.mu.Lock() | |
| 7337 | - | statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue | |
| 7338 | - | i.mu.Unlock() | |
| 7339 | - | continuationReason := classifyCompactionContinuation(request.origin, statusRescueActive, request.lastStop, request.turnError, msgsBefore) | |
| 7340 | - | if request.force { | |
| 7341 | - | continuationReason = compactContinuationForcedLength | |
| 7342 | - | } | |
| 7343 | - | // Keep the usual recent tail when possible, but never let it cover | |
| 7344 | - | // the whole transcript. A short session can still be at 70–90% of | |
| 7345 | - | // a model's window because one prompt or tool result is large; the | |
| 7346 | - | // automatic path must summarize that session instead of failing | |
| 7347 | - | // before it reaches the compaction provider. | |
| 7348 | - | keepTail := 4 | |
| 7349 | - | if n := len(msgsBefore); n > 0 && keepTail >= n { | |
| 7350 | - | keepTail = n - 1 | |
| 7351 | - | } | |
| 7352 | - | summary, err := i.agent.Compact(ctx, keepTail, sink) | |
| 7353 | - | _ = summary | |
| 7354 | - | goalMessage, goalActive := i.goalContinuationMessage() | |
| 7355 | - | i.mu.Lock() | |
| 7356 | - | // Keep busy/compacting asserted while cleanup and queue selection run. | |
| 7357 | - | // Completion updates can arrive in this window; clearing busy before | |
| 7358 | - | // inspecting the queues lets them race a new turn or be stranded. | |
| 7359 | - | i.resetStreamingStateLocked() | |
| 7360 | - | i.cancelTurn = nil | |
| 7361 | - | i.autoCompacting = false | |
| 7362 | - | pendingIdleWork := i.takePendingIdleWorkLocked() | |
| 7363 | - | ||
| 7364 | - | // Drain pending work after the transcript is clean. Overflow recovery | |
| 7365 | - | // continues the user message already in the transcript. Pre-turn | |
| 7366 | - | // compaction preserves its complete text-and-images request. Regular | |
| 7367 | - | // prompts typed during compaction remain in the host queue. | |
| 7368 | - | var next string | |
| 7369 | - | var nextImages []provider.ImageBlock | |
| 7370 | - | var hasNext bool | |
| 7371 | - | var continueExisting bool | |
| 7372 | - | var continueAutomatically bool | |
| 7373 | - | var continueGoal bool | |
| 7374 | - | var handoff json.RawMessage | |
| 7375 | - | var persistHandoff bool | |
| 7376 | - | ||
| 7377 | - | switch { | |
| 7378 | - | case err != nil && ctx.Err() != nil: | |
| 7379 | - | i.statusErr = "" | |
| 7380 | - | if auto { | |
| 7381 | - | i.statusOK = "auto-condense cancelled" | |
| 7382 | - | } else { | |
| 7383 | - | i.statusOK = "compaction cancelled" | |
| 7384 | - | } | |
| 7385 | - | i.queued = nil // drop queue on cancel | |
| 7386 | - | i.clearPendingCompactTurnLocked() | |
| 7387 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7388 | - | if i.agent != nil { | |
| 7389 | - | i.agent.DrainQueuedMessages() | |
| 7390 | - | } | |
| 7391 | - | case err != nil: | |
| 7392 | - | i.statusErr = "compaction failed: " + err.Error() | |
| 7393 | - | i.statusOK = "" | |
| 7394 | - | i.queued = nil // drop queue on error | |
| 7395 | - | i.clearPendingCompactTurnLocked() | |
| 7396 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7397 | - | if i.agent != nil { | |
| 7398 | - | i.agent.DrainQueuedMessages() | |
| 7399 | - | } | |
| 7400 | - | default: | |
| 7401 | - | i.statusErr = "" | |
| 7402 | - | // Read token count from the compaction message meta. | |
| 7403 | - | tokens := "" | |
| 7404 | - | msgs := i.agent.Messages() | |
| 7405 | - | if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" { | |
| 7406 | - | tokens = msgs[0].Meta["tokens_before"] | |
| 7407 | - | } | |
| 7408 | - | switch { | |
| 7409 | - | case i.pendingPostCompactNote != "": | |
| 7410 | - | i.statusOK = i.pendingPostCompactNote | |
| 7411 | - | case tokens != "": | |
| 7412 | - | i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens) | |
| 7413 | - | default: | |
| 7414 | - | i.statusOK = "compacted (ctrl+o to expand)" | |
| 7415 | - | } | |
| 7416 | - | i.pendingPostCompactNote = "" | |
| 7417 | - | i.extNotes = stripAutoCompactNotes(i.extNotes) | |
| 7418 | - | i.lastCtxInput = 0 | |
| 7419 | - | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 7420 | - | i.toolOrder = nil | |
| 7421 | - | i.toolGate = map[string]int{} | |
| 7422 | - | i.resetTranscriptRenderLocked() | |
| 7423 | - | switch { | |
| 7424 | - | case i.continueAfterCompact: | |
| 7425 | - | continueExisting = true | |
| 7426 | - | i.continueAfterCompact = false | |
| 7427 | - | case i.hasPendingCompactPrompt: | |
| 7428 | - | next = i.pendingCompactPrompt | |
| 7429 | - | nextImages = i.pendingCompactImages | |
| 7430 | - | i.pendingCompactPrompt = "" | |
| 7431 | - | i.pendingCompactImages = nil | |
| 7432 | - | i.hasPendingCompactPrompt = false | |
| 7433 | - | hasNext = true | |
| 7434 | - | case continuationReason == compactContinuationForcedLength: | |
| 7435 | - | // Forced truncated-output continuation keeps its existing | |
| 7436 | - | // priority over an explicitly queued prompt. | |
| 7437 | - | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason}) | |
| 7438 | - | continueAutomatically = true | |
| 7439 | - | case len(i.queued) > 0: | |
| 7440 | - | next, i.queued = i.queued[0], i.queued[1:] | |
| 7441 | - | hasNext = true | |
| 7442 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7443 | - | case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue: | |
| 7444 | - | if continuationReason == compactContinuationStatusRescue { | |
| 7445 | - | attempts := 1 | |
| 7446 | - | if i.compactContinuation.reason == compactContinuationStatusRescue { | |
| 7447 | - | attempts = i.compactContinuation.rescueAttempts + 1 | |
| 7448 | - | } | |
| 7449 | - | if attempts > maxStatusRescueContinuations { | |
| 7450 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7451 | - | break | |
| 7452 | - | } | |
| 7453 | - | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{ | |
| 7454 | - | reason: continuationReason, | |
| 7455 | - | rescueAttempts: attempts, | |
| 7456 | - | }) | |
| 7457 | - | } else { | |
| 7458 | - | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason}) | |
| 7459 | - | } | |
| 7460 | - | continueAutomatically = true | |
| 7461 | - | case auto && goalActive && !i.coordinatorHasPendingWorkers(): | |
| 7462 | - | continueGoal = true | |
| 7463 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7464 | - | default: | |
| 7465 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7466 | - | } | |
| 7467 | - | } | |
| 7468 | - | // Keep the host busy until the hand-off decision is committed under | |
| 7469 | - | // the mutex. A completion update arriving after the compaction result | |
| 7470 | - | // but before this assignment is then queued for the selected follow-up | |
| 7471 | - | // instead of starting a competing turn. | |
| 7472 | - | i.busy = hasNext || continueExisting || continueAutomatically || continueGoal | |
| 7473 | - | i.compacting = false | |
| 7474 | - | i.autoCompacting = false | |
| 7475 | - | i.mu.Unlock() | |
| 7476 | - | if persistHandoff { | |
| 7477 | - | i.persistCompactHandoff(handoff) | |
| 7478 | - | } | |
| 7479 | - | runPendingIdleWork(pendingIdleWork) | |
| 7480 | - | i.invalidate() | |
| 7481 | - | ||
| 7482 | - | if hasNext || continueExisting || continueAutomatically || continueGoal { | |
| 7483 | - | p := i.runCtx | |
| 7484 | - | if p == nil { | |
| 7485 | - | p = context.Background() | |
| 7486 | - | } | |
| 7487 | - | switch { | |
| 7488 | - | case continueExisting: | |
| 7489 | - | i.startTurnRequest(p, "", nil, true, true) | |
| 7490 | - | case hasNext: | |
| 7491 | - | i.startTurnWithImages(p, next, nextImages) | |
| 7492 | - | case continueAutomatically: | |
| 7493 | - | i.startAutoCompactContinuation(p) | |
| 7494 | - | case continueGoal: | |
| 7495 | - | i.startGoalContinuation(p, goalMessage) | |
| 7496 | - | } | |
| 7497 | - | } | |
| 7498 | - | }() | |
| 7499 | - | } | |
| 7500 | 1712 | ||
| 7501 | 1713 | // shellEscapeCommand reports whether text is a "!command" shell | |
| 7502 | 1714 | // escape and, if so, returns the command with the leading '!' (and | |
| 7503 | 1715 | // surrounding whitespace) stripped. A bare "!" with no command is | |
| 7504 | 1716 | // treated as not an escape so it falls through to the normal prompt | |
| 7505 | 1717 | // path rather than running an empty shell. | |
| 7506 | - | func shellEscapeCommand(text string) (string, bool) { | |
| 7507 | - | return ShellEscapeCommand(text) | |
| 7508 | - | } | |
| 7509 | 1718 | ||
| 7510 | 1719 | // ShellEscapeCommand reports whether text is a "!command" shell | |
| 7511 | 1720 | // escape and, if so, returns the command with the leading '!' (and | |
| @@ -7514,129 +1723,12 @@ func shellEscapeCommand(text string) (string, bool) { | |||
| 7514 | 1723 | // path rather than running an empty shell. | |
| 7515 | 1724 | const ShellEscapeTimeoutSeconds = 600 | |
| 7516 | 1725 | ||
| 7517 | - | func ShellEscapeCommand(text string) (string, bool) { | |
| 7518 | - | trimmed := strings.TrimLeft(text, " \t") | |
| 7519 | - | if !strings.HasPrefix(trimmed, "!") { | |
| 7520 | - | return "", false | |
| 7521 | - | } | |
| 7522 | - | cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!")) | |
| 7523 | - | if cmd == "" { | |
| 7524 | - | return "", false | |
| 7525 | - | } | |
| 7526 | - | return cmd, true | |
| 7527 | - | } | |
| 7528 | - | ||
| 7529 | 1726 | // startShellEscape runs a "!command" in the same shell the bash tool | |
| 7530 | 1727 | // uses, in the session working directory, honoring the /jail sandbox. | |
| 7531 | 1728 | // It shares the busy/cancel state with the agent: esc cancels it, and | |
| 7532 | 1729 | // it refuses to start while a turn or another shell escape is already | |
| 7533 | 1730 | // in flight. Its terminal-log output is appended to the transcript as | |
| 7534 | 1731 | // user context without automatically starting a model turn. | |
| 7535 | - | func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |
| 7536 | - | i.mu.Lock() | |
| 7537 | - | if i.busy || i.shellRunning { | |
| 7538 | - | i.statusErr = "busy — wait for the current turn to finish before running a shell command" | |
| 7539 | - | i.statusOK = "" | |
| 7540 | - | i.mu.Unlock() | |
| 7541 | - | i.invalidate() | |
| 7542 | - | return | |
| 7543 | - | } | |
| 7544 | - | if parent == nil { | |
| 7545 | - | parent = i.runCtx | |
| 7546 | - | } | |
| 7547 | - | if parent == nil { | |
| 7548 | - | parent = context.Background() | |
| 7549 | - | } | |
| 7550 | - | ctx, cancel := context.WithCancel(parent) | |
| 7551 | - | i.busy = true | |
| 7552 | - | i.shellRunning = true | |
| 7553 | - | i.shellLive = "$ " + cmd + "\n\n" | |
| 7554 | - | i.cancelTurn = cancel | |
| 7555 | - | i.statusErr = "" | |
| 7556 | - | i.statusOK = "" | |
| 7557 | - | i.spin.Start() | |
| 7558 | - | i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}} | |
| 7559 | - | // Clear stale extension notes the same way a new turn would so the | |
| 7560 | - | // screen doesn't accumulate transient state. | |
| 7561 | - | i.extNotes = nil | |
| 7562 | - | i.scrollOffset = 0 | |
| 7563 | - | i.parkedTurn = 0 | |
| 7564 | - | i.parkedTotal = 0 | |
| 7565 | - | i.helpBlock = nil | |
| 7566 | - | sandbox := i.cfg.Sandbox | |
| 7567 | - | cwd := i.cfg.CWD | |
| 7568 | - | i.mu.Unlock() | |
| 7569 | - | i.invalidate() | |
| 7570 | - | ||
| 7571 | - | go func() { | |
| 7572 | - | defer cancel() | |
| 7573 | - | raw, _ := json.Marshal(map[string]any{ | |
| 7574 | - | "command": cmd, | |
| 7575 | - | "timeout": ShellEscapeTimeoutSeconds, | |
| 7576 | - | }) | |
| 7577 | - | bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox} | |
| 7578 | - | progress := func(chunk string) { | |
| 7579 | - | i.mu.Lock() | |
| 7580 | - | i.shellLive += chunk | |
| 7581 | - | i.mu.Unlock() | |
| 7582 | - | i.invalidate() | |
| 7583 | - | } | |
| 7584 | - | res, err := bash.Execute(ctx, raw, progress) | |
| 7585 | - | ||
| 7586 | - | var out string | |
| 7587 | - | if err != nil { | |
| 7588 | - | out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]" | |
| 7589 | - | } else { | |
| 7590 | - | for _, c := range res.Content { | |
| 7591 | - | if tb, ok := c.(provider.TextBlock); ok { | |
| 7592 | - | out += tb.Text | |
| 7593 | - | } | |
| 7594 | - | } | |
| 7595 | - | } | |
| 7596 | - | cancelled := ctx.Err() != nil | |
| 7597 | - | failed := err != nil || res.IsError || cancelled | |
| 7598 | - | if cancelled { | |
| 7599 | - | out += "\n\n[cancelled]" | |
| 7600 | - | } | |
| 7601 | - | ||
| 7602 | - | if i.agent != nil { | |
| 7603 | - | i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"}) | |
| 7604 | - | } | |
| 7605 | - | ||
| 7606 | - | i.mu.Lock() | |
| 7607 | - | i.shellRunning = false | |
| 7608 | - | i.shellLive = "" | |
| 7609 | - | i.busy = false | |
| 7610 | - | i.cancelTurn = nil | |
| 7611 | - | pendingIdleWork := i.takePendingIdleWorkLocked() | |
| 7612 | - | awaitingPre := i.awaitingStartupPre | |
| 7613 | - | if failed { | |
| 7614 | - | if cancelled { | |
| 7615 | - | i.statusErr = "shell command cancelled" | |
| 7616 | - | } else { | |
| 7617 | - | i.statusErr = "shell command failed" | |
| 7618 | - | } | |
| 7619 | - | i.statusOK = "" | |
| 7620 | - | } else { | |
| 7621 | - | i.statusOK = "shell command finished" | |
| 7622 | - | i.statusErr = "" | |
| 7623 | - | } | |
| 7624 | - | i.mu.Unlock() | |
| 7625 | - | runPendingIdleWork(pendingIdleWork) | |
| 7626 | - | i.invalidate() | |
| 7627 | - | if awaitingPre { | |
| 7628 | - | i.completeStartupPre() | |
| 7629 | - | } | |
| 7630 | - | }() | |
| 7631 | - | } | |
| 7632 | - | ||
| 7633 | - | func (i *Interactive) startTurn(parent context.Context, prompt string) { | |
| 7634 | - | i.startTurnWithImages(parent, prompt, nil) | |
| 7635 | - | } | |
| 7636 | - | ||
| 7637 | - | func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) { | |
| 7638 | - | i.startTurnRequest(parent, prompt, images, false, false) | |
| 7639 | - | } | |
| 7640 | 1732 | ||
| 7641 | 1733 | // startTurnRequest starts a new prompt or continues an existing user turn. | |
| 7642 | 1734 | // continueExisting selects Agent.Continue so callers can retry a transcript | |
| @@ -7644,313 +1736,6 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||
| 7644 | 1736 | // the pre-turn threshold guard and the one-shot overflow recovery path; a | |
| 7645 | 1737 | // normal post-compaction continuation leaves it false so a still-too-large | |
| 7646 | 1738 | // rebuilt context can recover once more. | |
| 7647 | - | func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) { | |
| 7648 | - | if i.agent == nil { | |
| 7649 | - | // Text startup pre cannot run without credentials; continue so | |
| 7650 | - | // deferred InitialInput (pre-fill or auto-submit) still applies. | |
| 7651 | - | i.mu.Lock() | |
| 7652 | - | awaitingPre := i.awaitingStartupPre | |
| 7653 | - | i.mu.Unlock() | |
| 7654 | - | if awaitingPre { | |
| 7655 | - | i.completeStartupPre() | |
| 7656 | - | } | |
| 7657 | - | return | |
| 7658 | - | } | |
| 7659 | - | // Pre-turn safety: if the most recent context measurement is | |
| 7660 | - | // already past the auto-compact threshold, condense before | |
| 7661 | - | // sending so the next outbound request stays under the limit. | |
| 7662 | - | // The condense flow re-fires the user's queued prompt for us, so | |
| 7663 | - | // we just hand it off and exit. | |
| 7664 | - | i.mu.Lock() | |
| 7665 | - | var resetHandoff json.RawMessage | |
| 7666 | - | var persistResetHandoff bool | |
| 7667 | - | if !continueExisting { | |
| 7668 | - | resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked() | |
| 7669 | - | } | |
| 7670 | - | needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked() | |
| 7671 | - | if needsPreCompact { | |
| 7672 | - | // Reserve the compaction hand-off before releasing i.mu. A completion | |
| 7673 | - | // update arriving in the small gap before runCompact acquires the | |
| 7674 | - | // mutex must join the host queue rather than starting a competing turn. | |
| 7675 | - | i.busy = true | |
| 7676 | - | i.compacting = true | |
| 7677 | - | i.autoCompacting = true | |
| 7678 | - | if continueExisting { | |
| 7679 | - | i.continueAfterCompact = true | |
| 7680 | - | } else { | |
| 7681 | - | i.pendingCompactPrompt = prompt | |
| 7682 | - | i.pendingCompactImages = append([]provider.ImageBlock(nil), images...) | |
| 7683 | - | i.hasPendingCompactPrompt = true | |
| 7684 | - | } | |
| 7685 | - | i.statusErr = "" | |
| 7686 | - | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending...")) | |
| 7687 | - | i.pendingPostCompactNote = "context auto-compacted; sending your last message" | |
| 7688 | - | i.mu.Unlock() | |
| 7689 | - | if persistResetHandoff { | |
| 7690 | - | i.persistCompactHandoff(resetHandoff) | |
| 7691 | - | } | |
| 7692 | - | i.invalidate() | |
| 7693 | - | i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold}) | |
| 7694 | - | return | |
| 7695 | - | } | |
| 7696 | - | i.mu.Unlock() | |
| 7697 | - | if persistResetHandoff { | |
| 7698 | - | i.persistCompactHandoff(resetHandoff) | |
| 7699 | - | } | |
| 7700 | - | ||
| 7701 | - | ctx, cancel := context.WithCancel(parent) | |
| 7702 | - | i.mu.Lock() | |
| 7703 | - | i.busy = true | |
| 7704 | - | i.spin.Start() | |
| 7705 | - | i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model) | |
| 7706 | - | i.cancelTurn = cancel | |
| 7707 | - | i.statusErr = "" | |
| 7708 | - | i.statusOK = "" | |
| 7709 | - | i.streaming.Reset() | |
| 7710 | - | i.streamOn = true | |
| 7711 | - | i.pendingAlert = nil | |
| 7712 | - | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 7713 | - | i.toolOrder = nil | |
| 7714 | - | i.toolGate = map[string]int{} | |
| 7715 | - | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | |
| 7716 | - | i.scrollOffset = 0 // jump back to the bottom on new turn | |
| 7717 | - | // Lift the resume tail cap once the user starts interacting. The | |
| 7718 | - | // cap is purely a first-paint optimization (don't markdown the | |
| 7719 | - | // whole history before showing anything). Keeping it active during | |
| 7720 | - | // a turn makes the rendered chat a sliding window: appended | |
| 7721 | - | // messages push older ones off the TOP of the buffer, which the | |
| 7722 | - | // renderer must treat as a change above the viewport and repaint | |
| 7723 | - | // fully, snapping the terminal's native scrollback to the bottom on | |
| 7724 | - | // every streamed chunk. A fresh session has no cap (append-only), | |
| 7725 | - | // which is why the jump only shows up in resumed sessions. Dropping | |
| 7726 | - | // the cap here makes resumed turns append-only too. | |
| 7727 | - | i.view.TailLimit = 0 | |
| 7728 | - | // Reset the auto-follow baseline so the very next render at | |
| 7729 | - | // interactive.go:1053 doesn't see a synthetic shrink between | |
| 7730 | - | // "last frame had the previous turn's tool overlay" and | |
| 7731 | - | // "this frame had it cleared above". Without this, the guard | |
| 7732 | - | // reads delta = -(rows in cleared overlay) and decrements | |
| 7733 | - | // scrollOffset, which on terminals that mirror zut's pane | |
| 7734 | - | // scroll into the host scrollbar visibly yanks the viewport. | |
| 7735 | - | // See autofollow_shrink_test.go for the exact arithmetic. | |
| 7736 | - | i.prevChatLen = 0 | |
| 7737 | - | i.prevChatCols = 0 | |
| 7738 | - | i.parkedTurn = 0 // starting a turn clears the /jump parked state | |
| 7739 | - | i.parkedTotal = 0 | |
| 7740 | - | i.helpBlock = nil // hide the help block once the user asks something | |
| 7741 | - | i.mu.Unlock() | |
| 7742 | - | i.invalidate() | |
| 7743 | - | ||
| 7744 | - | var ( | |
| 7745 | - | lastStop provider.StopReason | |
| 7746 | - | lastTurnErr error | |
| 7747 | - | ) | |
| 7748 | - | sink := func(ev core.AgentEvent) { | |
| 7749 | - | if e, ok := ev.(core.EvTurnEnd); ok { | |
| 7750 | - | lastStop = e.Stop | |
| 7751 | - | lastTurnErr = e.Err | |
| 7752 | - | } | |
| 7753 | - | i.handleEventForPresentation(ev) | |
| 7754 | - | } | |
| 7755 | - | ||
| 7756 | - | releaseCompletionHold := i.beginCompletionDeliveryHold() | |
| 7757 | - | go func() { | |
| 7758 | - | defer releaseCompletionHold() | |
| 7759 | - | var err error | |
| 7760 | - | if continueExisting { | |
| 7761 | - | err = i.agent.Continue(ctx, sink) | |
| 7762 | - | } else { | |
| 7763 | - | err = i.agent.Prompt(ctx, prompt, images, sink) | |
| 7764 | - | } | |
| 7765 | - | i.mu.Lock() | |
| 7766 | - | // Keep busy asserted through final cleanup and queue selection. A | |
| 7767 | - | // watcher may enqueue a completion summary concurrently with the | |
| 7768 | - | // provider's final event; publishing idle before inspecting queues can | |
| 7769 | - | // strand that summary in the core agent queue or start an overlapping | |
| 7770 | - | // turn. | |
| 7771 | - | // Don't touch streamPending / streamFlushPending here — the | |
| 7772 | - | // pacer may still be draining the final deltas and needs to | |
| 7773 | - | // paint them even though Prompt has returned. It will reset | |
| 7774 | - | // streamOn on its own once the buffer empties. | |
| 7775 | - | if len(i.streamPending) == 0 { | |
| 7776 | - | i.streamOn = false | |
| 7777 | - | } | |
| 7778 | - | i.cancelTurn = nil | |
| 7779 | - | pendingIdleWork := i.takePendingIdleWorkLocked() | |
| 7780 | - | if err != nil && ctx.Err() == nil { | |
| 7781 | - | i.statusErr = err.Error() | |
| 7782 | - | } | |
| 7783 | - | // Decide whether to offer a model rescue picker for recoverable | |
| 7784 | - | // provider failures (auth/rate/temporary). The picker opens after | |
| 7785 | - | // the mutex is released so it can take its own locks freely. | |
| 7786 | - | var ( | |
| 7787 | - | offer bool | |
| 7788 | - | rescueWhy string | |
| 7789 | - | rescueImgs []provider.ImageBlock | |
| 7790 | - | rescueModel string | |
| 7791 | - | rescueProv string | |
| 7792 | - | rescueFprov string | |
| 7793 | - | ) | |
| 7794 | - | if err != nil && ctx.Err() == nil { | |
| 7795 | - | if ok, reason := classifyRescueError(err); ok { | |
| 7796 | - | offer = true | |
| 7797 | - | rescueWhy = reason | |
| 7798 | - | rescueImgs = images | |
| 7799 | - | rescueModel = i.cfg.Model | |
| 7800 | - | rescueProv = i.cfg.Provider | |
| 7801 | - | rescueFprov = extractFailedProvider(err) | |
| 7802 | - | if rescueFprov == "" { | |
| 7803 | - | rescueFprov = i.cfg.Provider | |
| 7804 | - | } | |
| 7805 | - | // Suppress the red banner — the rescue dialog already | |
| 7806 | - | // surfaces the failure. | |
| 7807 | - | i.statusErr = "" | |
| 7808 | - | } | |
| 7809 | - | } | |
| 7810 | - | // Detect responses that reject the current context, either as an | |
| 7811 | - | // HTTP 413 payload limit or a model context-window error. Token- | |
| 7812 | - | // based auto-compact can miss both when metadata is stale or the | |
| 7813 | - | // limit is measured in raw bytes. Compact once, then continue the | |
| 7814 | - | // user message already present in the transcript. | |
| 7815 | - | contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err) | |
| 7816 | - | recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted | |
| 7817 | - | if recoverContextOverflow { | |
| 7818 | - | i.statusErr = "" | |
| 7819 | - | i.continueAfterCompact = true | |
| 7820 | - | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ...")) | |
| 7821 | - | i.pendingPostCompactNote = "context auto-compacted; retrying your last message" | |
| 7822 | - | } | |
| 7823 | - | // Persist the assistant's reply (and every tool row before | |
| 7824 | - | // it) to the session file while the turn memory is hot. | |
| 7825 | - | // Without this, WriteNewTranscript only fires at zut exit, | |
| 7826 | - | // meaning a crash or ungraceful kill drops the whole | |
| 7827 | - | // conversation. FlushSession is idempotent (it advances the | |
| 7828 | - | // baseline so subsequent flushes only write new rows). | |
| 7829 | - | flush := i.cfg.FlushSession | |
| 7830 | - | i.mu.Unlock() | |
| 7831 | - | runPendingIdleWork(pendingIdleWork) | |
| 7832 | - | if flush != nil { | |
| 7833 | - | flush() | |
| 7834 | - | } | |
| 7835 | - | terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError) | |
| 7836 | - | if terminalGoalError { | |
| 7837 | - | i.updateActiveGoal(core.GoalBlocked, "turn ended with an error") | |
| 7838 | - | } | |
| 7839 | - | i.mu.Lock() | |
| 7840 | - | awaitingPre := i.awaitingStartupPre | |
| 7841 | - | // A newer explicit prompt may have cleared the handoff while the | |
| 7842 | - | // completed turn was being flushed. Re-read it under the mutex before | |
| 7843 | - | // deciding whether this continuation may spend another rescue attempt. | |
| 7844 | - | statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue | |
| 7845 | - | // Pop the next queued message, if any, and relaunch. | |
| 7846 | - | var next string | |
| 7847 | - | var hasNext bool | |
| 7848 | - | if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil { | |
| 7849 | - | next, i.queued = i.queued[0], i.queued[1:] | |
| 7850 | - | hasNext = true | |
| 7851 | - | } | |
| 7852 | - | // If the turn was cancelled or errored, drop the queue so the | |
| 7853 | - | // user isn't bombarded with stale messages after an interrupt. | |
| 7854 | - | if ctx.Err() != nil || (err != nil && !recoverContextOverflow) { | |
| 7855 | - | i.queued = nil | |
| 7856 | - | if i.agent != nil { | |
| 7857 | - | i.agent.DrainQueuedMessages() | |
| 7858 | - | } | |
| 7859 | - | } | |
| 7860 | - | // Decide whether the next thing to do is an auto-compaction. | |
| 7861 | - | // Only fires when the turn completed cleanly AND no host-side | |
| 7862 | - | // or agent-side queued messages are waiting (otherwise a queued | |
| 7863 | - | // message would race the condense). | |
| 7864 | - | agentQueued := 0 | |
| 7865 | - | if i.agent != nil { | |
| 7866 | - | agentQueued = i.agent.QueuedMessageCount() | |
| 7867 | - | } | |
| 7868 | - | continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil | |
| 7869 | - | shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked() | |
| 7870 | - | continueStatusRescue := false | |
| 7871 | - | var handoff json.RawMessage | |
| 7872 | - | var persistHandoff bool | |
| 7873 | - | if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil { | |
| 7874 | - | followUpMessages := i.agent.Messages() | |
| 7875 | - | reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages) | |
| 7876 | - | if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations { | |
| 7877 | - | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{ | |
| 7878 | - | reason: compactContinuationStatusRescue, | |
| 7879 | - | rescueAttempts: i.compactContinuation.rescueAttempts + 1, | |
| 7880 | - | }) | |
| 7881 | - | continueStatusRescue = true | |
| 7882 | - | } else { | |
| 7883 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7884 | - | } | |
| 7885 | - | } | |
| 7886 | - | if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) { | |
| 7887 | - | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7888 | - | } | |
| 7889 | - | // The agent run can finish before the paced final text reaches the | |
| 7890 | - | // transcript. A compaction replaces that transcript, so it must never | |
| 7891 | - | // race the still-live stream frame; otherwise stale deltas can repaint | |
| 7892 | - | // after the replacement and corrupt the scrollback renderer's model. | |
| 7893 | - | if recoverContextOverflow || shouldAutoCompact { | |
| 7894 | - | i.resetStreamingStateLocked() | |
| 7895 | - | } | |
| 7896 | - | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact) | |
| 7897 | - | i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact | |
| 7898 | - | i.mu.Unlock() | |
| 7899 | - | if persistHandoff { | |
| 7900 | - | i.persistCompactHandoff(handoff) | |
| 7901 | - | } | |
| 7902 | - | if alertReason != "" { | |
| 7903 | - | i.scheduleMainAlert(alertReason) | |
| 7904 | - | } | |
| 7905 | - | i.invalidate() | |
| 7906 | - | parent := i.runCtx | |
| 7907 | - | if parent == nil { | |
| 7908 | - | parent = context.Background() | |
| 7909 | - | } | |
| 7910 | - | if awaitingPre { | |
| 7911 | - | i.completeStartupPre() | |
| 7912 | - | return | |
| 7913 | - | } | |
| 7914 | - | switch { | |
| 7915 | - | case hasNext: | |
| 7916 | - | i.startTurn(parent, next) | |
| 7917 | - | case continueQueued: | |
| 7918 | - | i.startTurnRequest(parent, "", nil, true, false) | |
| 7919 | - | case continueStatusRescue: | |
| 7920 | - | i.startAutoCompactContinuation(parent) | |
| 7921 | - | case offer: | |
| 7922 | - | i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs) | |
| 7923 | - | case recoverContextOverflow: | |
| 7924 | - | i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery}) | |
| 7925 | - | case shouldAutoCompact: | |
| 7926 | - | i.runCompact(parent, compactContinuationRequest{ | |
| 7927 | - | origin: compactOriginAfterTurnThreshold, | |
| 7928 | - | force: lastStop == provider.StopLength, | |
| 7929 | - | lastStop: lastStop, | |
| 7930 | - | turnError: lastTurnErr, | |
| 7931 | - | }) | |
| 7932 | - | } | |
| 7933 | - | }() | |
| 7934 | - | } | |
| 7935 | - | ||
| 7936 | - | func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string { | |
| 7937 | - | if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting { | |
| 7938 | - | return "" | |
| 7939 | - | } | |
| 7940 | - | if rescue { | |
| 7941 | - | return "rescue_required" | |
| 7942 | - | } | |
| 7943 | - | if stop == provider.StopAborted { | |
| 7944 | - | return "" | |
| 7945 | - | } | |
| 7946 | - | if err != nil || turnErr != nil || stop == provider.StopError { | |
| 7947 | - | return "agent_error" | |
| 7948 | - | } | |
| 7949 | - | if stop == provider.StopLength { | |
| 7950 | - | return "response_truncated" | |
| 7951 | - | } | |
| 7952 | - | return "agent_done" | |
| 7953 | - | } | |
| 7954 | 1739 | ||
| 7955 | 1740 | // openRescueDialog surfaces the rescue model picker after a recoverable | |
| 7956 | 1741 | // provider failure. The pending prompt + images are stashed on the | |
| @@ -7958,546 +1743,61 @@ func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.Stop | |||
| 7958 | 1743 | // against the freshly-picked model. activeProvider/failedProvider are | |
| 7959 | 1744 | // usually the same, but some clients embed different prefixes in their | |
| 7960 | 1745 | // errors than the configured provider id, so we accept both. | |
| 7961 | - | func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) { | |
| 7962 | - | if i.rescueDialog == nil { | |
| 7963 | - | return | |
| 7964 | - | } | |
| 7965 | - | loggedIn := []string{} | |
| 7966 | - | if i.cfg.LoggedInProviders != nil { | |
| 7967 | - | loggedIn = i.cfg.LoggedInProviders() | |
| 7968 | - | } | |
| 7969 | - | fprov := failedProvider | |
| 7970 | - | if fprov == "" { | |
| 7971 | - | fprov = activeProvider | |
| 7972 | - | } | |
| 7973 | - | i.mu.Lock() | |
| 7974 | - | i.pendingRescuePrompt = prompt | |
| 7975 | - | i.pendingRescueImages = images | |
| 7976 | - | i.mu.Unlock() | |
| 7977 | - | i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt) | |
| 7978 | - | i.invalidate() | |
| 7979 | - | } | |
| 7980 | 1746 | ||
| 7981 | 1747 | // applyRescueSelection switches model (cross-provider if needed) and | |
| 7982 | 1748 | // re-runs the same prompt+images that just failed. Mirrors | |
| 7983 | 1749 | // applyModelSelection's transcript-carry logic so the user keeps full | |
| 7984 | 1750 | // session continuity across the swap. | |
| 7985 | - | func (i *Interactive) applyRescueSelection(prov, model, prompt string) { | |
| 7986 | - | if model == "" { | |
| 7987 | - | return | |
| 7988 | - | } | |
| 7989 | - | i.applyRescueModelSelection(prov, model) | |
| 7990 | - | i.mu.Lock() | |
| 7991 | - | images := i.pendingRescueImages | |
| 7992 | - | if prompt == "" { | |
| 7993 | - | prompt = i.pendingRescuePrompt | |
| 7994 | - | } | |
| 7995 | - | i.pendingRescuePrompt = "" | |
| 7996 | - | i.pendingRescueImages = nil | |
| 7997 | - | i.mu.Unlock() | |
| 7998 | - | parent := i.runCtx | |
| 7999 | - | if parent == nil { | |
| 8000 | - | parent = context.Background() | |
| 8001 | - | } | |
| 8002 | - | i.startTurnWithImages(parent, prompt, images) | |
| 8003 | - | } | |
| 8004 | - | ||
| 8005 | - | func stripAutoCompactNotes(notes []string) []string { | |
| 8006 | - | if len(notes) == 0 { | |
| 8007 | - | return notes | |
| 8008 | - | } | |
| 8009 | - | out := notes[:0] | |
| 8010 | - | for _, n := range notes { | |
| 8011 | - | if strings.Contains(n, "condensing history") { | |
| 8012 | - | continue | |
| 8013 | - | } | |
| 8014 | - | out = append(out, n) | |
| 8015 | - | } | |
| 8016 | - | return out | |
| 8017 | - | } | |
| 8018 | 1751 | ||
| 8019 | 1752 | // autoCompactNoteLine returns a styled chat-area note for the | |
| 8020 | 1753 | // inline auto-compact heads-up. Lives in extNotes so it survives | |
| 8021 | 1754 | // the busy-spinner overwrite of the status row. | |
| 8022 | - | func autoCompactNoteLine(th tui.Theme, msg string) string { | |
| 8023 | - | return " " + th.FGColor(th.Warning, "⚠ "+msg) | |
| 8024 | - | } | |
| 8025 | 1755 | ||
| 8026 | 1756 | // NormalizeAutoCompactThreshold applies the persisted auto-compaction | |
| 8027 | 1757 | // setting's supported values and default. It is exported so non-interactive | |
| 8028 | 1758 | // hosts can share the interactive mode's exact configuration semantics. | |
| 8029 | - | func NormalizeAutoCompactThreshold(threshold *int) int { | |
| 8030 | - | const defaultAutoCompactThreshold = 85 | |
| 8031 | - | if threshold == nil { | |
| 8032 | - | return defaultAutoCompactThreshold | |
| 8033 | - | } | |
| 8034 | - | switch *threshold { | |
| 8035 | - | case 0, 70, 80, 85, 90, 95: | |
| 8036 | - | return *threshold | |
| 8037 | - | default: | |
| 8038 | - | return defaultAutoCompactThreshold | |
| 8039 | - | } | |
| 8040 | - | } | |
| 8041 | 1759 | ||
| 8042 | 1760 | // ShouldAutoCompact reports whether the latest request context reached the | |
| 8043 | 1761 | // configured percentage of the model's advertised context window. | |
| 8044 | - | func ShouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool { | |
| 8045 | - | if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 { | |
| 8046 | - | return false | |
| 8047 | - | } | |
| 8048 | - | return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100 | |
| 8049 | - | } | |
| 8050 | 1762 | ||
| 8051 | 1763 | // classifyCompactionContinuation chooses a private handoff reason from the | |
| 8052 | 1764 | // successful turn tail. Structural unfinished-tail handling is independent of | |
| 8053 | 1765 | // the status matcher. A status rescue is admitted only for an after-turn | |
| 8054 | 1766 | // threshold compaction or an already-active status handoff, and only after a | |
| 8055 | 1767 | // normal successful end. | |
| 8056 | - | func classifyCompactionContinuation(origin compactContinuationOrigin, statusRescueActive bool, stop provider.StopReason, turnErr error, msgs []provider.Message) compactContinuationReason { | |
| 8057 | - | if origin != compactOriginManual && structuralCompactionContinuation(msgs) { | |
| 8058 | - | return compactContinuationStructuralTail | |
| 8059 | - | } | |
| 8060 | - | if (origin != compactOriginAfterTurnThreshold && !statusRescueActive) || stop != provider.StopEnd || turnErr != nil { | |
| 8061 | - | return compactContinuationNone | |
| 8062 | - | } | |
| 8063 | - | if likelyForwardWorkStatus(msgs) { | |
| 8064 | - | return compactContinuationStatusRescue | |
| 8065 | - | } | |
| 8066 | - | return compactContinuationNone | |
| 8067 | - | } | |
| 8068 | 1768 | ||
| 8069 | - | // structuralCompactionContinuation preserves the pre-existing unfinished-tail | |
| 8070 | - | // behavior without consulting the status matcher. | |
| 8071 | - | func structuralCompactionContinuation(msgs []provider.Message) bool { | |
| 8072 | - | if len(msgs) == 0 { | |
| 8073 | - | return false | |
| 8074 | - | } | |
| 8075 | - | last := msgs[len(msgs)-1] | |
| 8076 | - | if last.Role != provider.RoleAssistant { | |
| 8077 | - | return true | |
| 8078 | - | } | |
| 8079 | - | hasText := false | |
| 8080 | - | for _, content := range last.Content { | |
| 8081 | - | switch block := content.(type) { | |
| 8082 | - | case provider.ToolCallBlock: | |
| 8083 | - | return true | |
| 8084 | - | case provider.TextBlock: | |
| 8085 | - | if strings.TrimSpace(block.Text) != "" { | |
| 8086 | - | hasText = true | |
| 8087 | - | } | |
| 8088 | - | case provider.ReasoningBlock: | |
| 8089 | - | // Reasoning without a visible answer is not terminal. | |
| 8090 | - | default: | |
| 8091 | - | return true | |
| 8092 | - | } | |
| 8093 | - | } | |
| 8094 | - | return !hasText | |
| 8095 | - | } | |
| 1769 | + | // structuralCompactionContinuation preserves the pre-existing unfinished-tail | |
| 1770 | + | // behavior without consulting the status matcher. | |
| 8096 | 1771 | ||
| 8097 | 1772 | // likelyForwardWorkStatus is deliberately narrow. It recognizes only clear | |
| 8098 | 1773 | // first-person future-work commitments and never treats generic plan prose as | |
| 8099 | 1774 | // a host control signal. | |
| 8100 | - | func likelyForwardWorkStatus(msgs []provider.Message) bool { | |
| 8101 | - | if len(msgs) == 0 { | |
| 8102 | - | return false | |
| 8103 | - | } | |
| 8104 | - | last := msgs[len(msgs)-1] | |
| 8105 | - | if last.Role != provider.RoleAssistant { | |
| 8106 | - | return false | |
| 8107 | - | } | |
| 8108 | - | var text strings.Builder | |
| 8109 | - | for _, content := range last.Content { | |
| 8110 | - | block, ok := content.(provider.TextBlock) | |
| 8111 | - | if !ok { | |
| 8112 | - | if _, hasTool := content.(provider.ToolCallBlock); hasTool { | |
| 8113 | - | return false | |
| 8114 | - | } | |
| 8115 | - | continue | |
| 8116 | - | } | |
| 8117 | - | if strings.TrimSpace(block.Text) == "" { | |
| 8118 | - | continue | |
| 8119 | - | } | |
| 8120 | - | if text.Len() > 0 { | |
| 8121 | - | text.WriteByte(' ') | |
| 8122 | - | } | |
| 8123 | - | text.WriteString(block.Text) | |
| 8124 | - | } | |
| 8125 | - | if text.Len() == 0 { | |
| 8126 | - | return false | |
| 8127 | - | } | |
| 8128 | - | normalized := strings.ToLower(strings.Join(strings.Fields(text.String()), " ")) | |
| 8129 | - | normalized = strings.NewReplacer("’", "'", "‘", "'", "`", "'").Replace(normalized) | |
| 8130 | - | for _, phrase := range []string{ | |
| 8131 | - | "next i will", | |
| 8132 | - | "next, i will", | |
| 8133 | - | "i'll now", | |
| 8134 | - | "i will now", | |
| 8135 | - | "i am going to", | |
| 8136 | - | "i need to continue", | |
| 8137 | - | "then i will", | |
| 8138 | - | "then, i will", | |
| 8139 | - | "i will proceed", | |
| 8140 | - | } { | |
| 8141 | - | if containsFutureWorkPhrase(normalized, phrase) { | |
| 8142 | - | return true | |
| 8143 | - | } | |
| 8144 | - | } | |
| 8145 | - | return false | |
| 8146 | - | } | |
| 8147 | - | ||
| 8148 | - | func containsFutureWorkPhrase(text, phrase string) bool { | |
| 8149 | - | for offset := 0; offset < len(text); { | |
| 8150 | - | index := strings.Index(text[offset:], phrase) | |
| 8151 | - | if index < 0 { | |
| 8152 | - | return false | |
| 8153 | - | } | |
| 8154 | - | index += offset | |
| 8155 | - | end := index + len(phrase) | |
| 8156 | - | if !wordRuneBefore(text, index) && !wordRuneAfter(text, end) { | |
| 8157 | - | return true | |
| 8158 | - | } | |
| 8159 | - | offset = end | |
| 8160 | - | } | |
| 8161 | - | return false | |
| 8162 | - | } | |
| 8163 | - | ||
| 8164 | - | func wordRuneBefore(text string, index int) bool { | |
| 8165 | - | if index == 0 { | |
| 8166 | - | return false | |
| 8167 | - | } | |
| 8168 | - | rune_, _ := utf8.DecodeLastRuneInString(text[:index]) | |
| 8169 | - | return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_' | |
| 8170 | - | } | |
| 8171 | - | ||
| 8172 | - | func wordRuneAfter(text string, index int) bool { | |
| 8173 | - | if index == len(text) { | |
| 8174 | - | return false | |
| 8175 | - | } | |
| 8176 | - | rune_, _ := utf8.DecodeRuneInString(text[index:]) | |
| 8177 | - | return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_' | |
| 8178 | - | } | |
| 8179 | 1775 | ||
| 8180 | 1776 | // shouldAutoCompactLocked reports whether the last turn pushed context | |
| 8181 | 1777 | // usage past the auto-compact threshold. Must be called with i.mu | |
| 8182 | 1778 | // held; it reads lastCtxInput and the current model's context window. | |
| 8183 | - | func (i *Interactive) shouldAutoCompactLocked() bool { | |
| 8184 | - | if i.agent == nil { | |
| 8185 | - | return false | |
| 8186 | - | } | |
| 8187 | - | if i.autoCompacting { | |
| 8188 | - | return false | |
| 8189 | - | } | |
| 8190 | - | m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model) | |
| 8191 | - | if err != nil || m.ContextWindow <= 0 { | |
| 8192 | - | return false | |
| 8193 | - | } | |
| 8194 | - | threshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold) | |
| 8195 | - | return ShouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold) | |
| 8196 | - | } | |
| 8197 | - | ||
| 8198 | - | func eventAffectsPresentation(ev core.AgentEvent) bool { | |
| 8199 | - | switch ev.(type) { | |
| 8200 | - | case core.EvToolProgress: | |
| 8201 | - | return false | |
| 8202 | - | default: | |
| 8203 | - | return true | |
| 8204 | - | } | |
| 8205 | - | } | |
| 8206 | - | ||
| 8207 | - | func (i *Interactive) bumpToolRevisionLocked(tc *tui.ToolCallView) { | |
| 8208 | - | i.toolRenderRevision++ | |
| 8209 | - | if i.toolRenderRevision == 0 { | |
| 8210 | - | // Overflow is practically unreachable, but resetting the sequence must | |
| 8211 | - | // also drop old revisions before they can alias new frames. | |
| 8212 | - | if i.view != nil { | |
| 8213 | - | i.view.InvalidateRenderCache() | |
| 8214 | - | } | |
| 8215 | - | i.toolRenderRevision = 1 | |
| 8216 | - | } | |
| 8217 | - | tc.Revision = i.toolRenderRevision | |
| 8218 | - | } | |
| 8219 | - | ||
| 8220 | - | func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) { | |
| 8221 | - | i.handleEvent(ev) | |
| 8222 | - | // Progress payloads are still delivered to the core sink and applied | |
| 8223 | - | // in order, but they have no interactive-visible representation. | |
| 8224 | - | if eventAffectsPresentation(ev) { | |
| 8225 | - | i.invalidate() | |
| 8226 | - | } | |
| 8227 | - | } | |
| 8228 | - | ||
| 8229 | - | func (i *Interactive) handleEvent(ev core.AgentEvent) { | |
| 8230 | - | i.mu.Lock() | |
| 8231 | - | defer i.mu.Unlock() | |
| 8232 | - | i.activity.apply(ev) | |
| 8233 | - | switch e := ev.(type) { | |
| 8234 | - | case core.EvAssistantStart: | |
| 8235 | - | // Fires at the top of every oneTurn, including follow-up | |
| 8236 | - | // turns after tool use. Without this, the streaming buffer | |
| 8237 | - | // is still marked off from the previous assistant message | |
| 8238 | - | // and the final summary text pops in all at once instead | |
| 8239 | - | // of typewriter-streaming delta by delta. | |
| 8240 | - | i.streaming.Reset() | |
| 8241 | - | i.streamPending = i.streamPending[:0] | |
| 8242 | - | i.streamFlushPending = false | |
| 8243 | - | i.streamOn = true | |
| 8244 | - | // Clear the live tool-call overlay. Any tools from the | |
| 8245 | - | // previous round are now fully folded into the transcript | |
| 8246 | - | // (assistant tool_use block + tool role message with the | |
| 8247 | - | // result), so keeping them in the overlay would duplicate | |
| 8248 | - | // them in the view — once inside the finalised transcript | |
| 8249 | - | // and once below the streaming block, with the streaming | |
| 8250 | - | // summary sandwiched in between. The next EvToolUseStart | |
| 8251 | - | // will populate fresh entries for this turn's tools. | |
| 8252 | - | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 8253 | - | i.toolOrder = nil | |
| 8254 | - | i.toolGate = map[string]int{} | |
| 8255 | - | case core.EvTextDelta: | |
| 8256 | - | // Buffer into streamPending; the paintPace ticker drains | |
| 8257 | - | // it into i.streaming a few runes at a time for a smooth | |
| 8258 | - | // typewriter effect independent of upstream chunk size. | |
| 8259 | - | i.streamPending = append(i.streamPending, []rune(e.Delta)...) | |
| 8260 | - | i.streamOn = true | |
| 8261 | - | case core.EvAssistantMessage: | |
| 8262 | - | // OnAssistant + telegram mirroring always fire on message | |
| 8263 | - | // arrival — they read the FINAL message content, which is | |
| 8264 | - | // complete regardless of what's still in the pacer. | |
| 8265 | - | i.assistantMessageSideEffects(e.Message) | |
| 8266 | - | // If the pacer still has characters to drain, keep streamOn | |
| 8267 | - | // true and mark flush pending; the paintPace ticker will | |
| 8268 | - | // drain the remainder and reset streaming state when done. | |
| 8269 | - | // Otherwise (rare: full-replay sessions, abort paths) clear | |
| 8270 | - | // synchronously so a later render doesn't show stale text. | |
| 8271 | - | if len(i.streamPending) > 0 { | |
| 8272 | - | i.streamFlushPending = true | |
| 8273 | - | return | |
| 8274 | - | } | |
| 8275 | - | i.resetStreamingStateLocked() | |
| 8276 | - | case core.EvToolUseStart: | |
| 8277 | - | // Live streaming: pre-create the view so the user sees the | |
| 8278 | - | // tool call being composed in real time. Any subsequent | |
| 8279 | - | // EvToolCall for the same ID updates the same struct (the | |
| 8280 | - | // final parsed args + name are already known here). | |
| 8281 | - | if _, exists := i.toolCalls[e.ID]; !exists { | |
| 8282 | - | tc := &tui.ToolCallView{ | |
| 8283 | - | ID: e.ID, | |
| 8284 | - | Name: e.Name, | |
| 8285 | - | Streaming: true, | |
| 8286 | - | } | |
| 8287 | - | i.bumpToolRevisionLocked(tc) | |
| 8288 | - | i.toolCalls[e.ID] = tc | |
| 8289 | - | i.toolOrder = append(i.toolOrder, e.ID) | |
| 8290 | - | i.gateToolLocked(e.ID) | |
| 8291 | - | } | |
| 8292 | - | case core.EvToolUseArgs: | |
| 8293 | - | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 8294 | - | tc.RawJSONBuf += e.Delta | |
| 8295 | - | i.bumpToolRevisionLocked(tc) | |
| 8296 | - | // Refresh the live path as soon as it parses; used in | |
| 8297 | - | // the header (write /Users/example/Desktop/demo.ts) | |
| 8298 | - | // while the content is still streaming. | |
| 8299 | - | if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok { | |
| 8300 | - | tc.LivePath = p | |
| 8301 | - | } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok { | |
| 8302 | - | tc.LivePath = p | |
| 8303 | - | } | |
| 8304 | - | } | |
| 8305 | - | case core.EvToolUseEnd: | |
| 8306 | - | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 8307 | - | tc.Streaming = false | |
| 8308 | - | i.bumpToolRevisionLocked(tc) | |
| 8309 | - | } | |
| 8310 | - | case core.EvToolCall: | |
| 8311 | - | // If we already pre-created the view during streaming, just | |
| 8312 | - | // refresh the final Args summary. Otherwise create a new one | |
| 8313 | - | // (non-streaming providers or legacy paths). | |
| 8314 | - | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 8315 | - | tc.Args = tui.ShortArgs(e.Name, e.Args) | |
| 8316 | - | i.bumpToolRevisionLocked(tc) | |
| 8317 | - | if tc.RawJSONBuf == "" { | |
| 8318 | - | tc.RawJSONBuf = string(e.Args) | |
| 8319 | - | } | |
| 8320 | - | tc.Streaming = false | |
| 8321 | - | } else { | |
| 8322 | - | tc := &tui.ToolCallView{ | |
| 8323 | - | ID: e.ID, | |
| 8324 | - | Name: e.Name, | |
| 8325 | - | Args: tui.ShortArgs(e.Name, e.Args), | |
| 8326 | - | RawJSONBuf: string(e.Args), | |
| 8327 | - | } | |
| 8328 | - | i.bumpToolRevisionLocked(tc) | |
| 8329 | - | i.toolCalls[e.ID] = tc | |
| 8330 | - | i.toolOrder = append(i.toolOrder, e.ID) | |
| 8331 | - | i.gateToolLocked(e.ID) | |
| 8332 | - | } | |
| 8333 | - | case core.EvToolResult: | |
| 8334 | - | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 8335 | - | tc.Done = true | |
| 8336 | - | tc.Error = e.Result.IsError | |
| 8337 | - | tc.Preview = "" | |
| 8338 | - | var text strings.Builder | |
| 8339 | - | for _, c := range e.Result.Content { | |
| 8340 | - | if tb, ok := c.(provider.TextBlock); ok { | |
| 8341 | - | if text.Len() > 0 { | |
| 8342 | - | text.WriteString("\n") | |
| 8343 | - | } | |
| 8344 | - | text.WriteString(tb.Text) | |
| 8345 | - | } | |
| 8346 | - | } | |
| 8347 | - | tc.Result = text.String() | |
| 8348 | - | i.bumpToolRevisionLocked(tc) | |
| 8349 | - | } | |
| 8350 | - | if update, ok := tools.GoalUpdateFromResult(e.Result); ok { | |
| 8351 | - | if update.Status == core.GoalActive { | |
| 8352 | - | // A manager may advance a terminal goal to the next persisted | |
| 8353 | - | // goal in the same mission. Do not let a late tool result resume | |
| 8354 | - | // a goal explicitly paused by the user. | |
| 8355 | - | if i.goalStatus != core.GoalPaused { | |
| 8356 | - | i.goalStatus = core.GoalActive | |
| 8357 | - | } | |
| 8358 | - | } else if i.goalStatus == core.GoalActive { | |
| 8359 | - | i.goalStatus = update.Status | |
| 8360 | - | } | |
| 8361 | - | } | |
| 8362 | - | case core.EvUsage: | |
| 8363 | - | i.cumUsage = e.Cumulative | |
| 8364 | - | if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 { | |
| 8365 | - | i.lastCtxInput = contextUsed | |
| 8366 | - | } | |
| 8367 | - | case core.EvTurnEnd: | |
| 8368 | - | if e.Stop == provider.StopAborted { | |
| 8369 | - | i.resetStreamingStateLocked() | |
| 8370 | - | i.statusErr = "" | |
| 8371 | - | i.statusOK = "cancelled" | |
| 8372 | - | return | |
| 8373 | - | } | |
| 8374 | - | if e.Stop == provider.StopLength { | |
| 8375 | - | // The model hit its output-token cap mid-response, so the | |
| 8376 | - | // reply (often a long write/edit) is truncated. Surface it | |
| 8377 | - | // explicitly, otherwise the turn just ends and reads like | |
| 8378 | - | // the UI gave up. The agent already requests the model's | |
| 8379 | - | // full MaxOutput budget, so this means the response genuinely | |
| 8380 | - | // exceeded that ceiling; ask the user to continue. | |
| 8381 | - | i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue" | |
| 8382 | - | i.statusOK = "" | |
| 8383 | - | return | |
| 8384 | - | } | |
| 8385 | - | // Don't surface mid-loop stream errors as a red banner here. | |
| 8386 | - | // EvTurnEnd fires after every step in a multi-step tool loop, | |
| 8387 | - | // so a transient 503 / network blip would briefly paint a red | |
| 8388 | - | // banner over the still-streaming chat before the agent loop | |
| 8389 | - | // either retries or exits. The final error (if any) is set by | |
| 8390 | - | // startTurnWithImages once Prompt() returns, and recoverable | |
| 8391 | - | // failures are routed to the rescue picker instead — which | |
| 8392 | - | // keeps the chat clean while the agent is working. | |
| 8393 | - | _ = e.Err | |
| 8394 | - | } | |
| 8395 | - | } | |
| 8396 | 1779 | ||
| 8397 | 1780 | // WebSearchPolicyGeneration returns the generation a resolver must present at | |
| 8398 | 1781 | // its final interactive commit. Policy transitions advance it before changing | |
| 8399 | 1782 | // the gate or generic-child ceiling. | |
| 8400 | - | func (i *Interactive) WebSearchPolicyGeneration() uint64 { | |
| 8401 | - | return i.webSearchPolicyGeneration.Load() | |
| 8402 | - | } | |
| 8403 | 1783 | ||
| 8404 | 1784 | // ApplyAgentPromptConfig commits a resolved prompt and tool registry only if | |
| 8405 | 1785 | // ag is still the live interactive agent. Callers without a long-running | |
| 8406 | 1786 | // resolve use the current policy generation. | |
| 8407 | - | func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) { | |
| 8408 | - | return i.applyAgentPromptConfig(ag, system, tools, 0, false) | |
| 8409 | - | } | |
| 8410 | 1787 | ||
| 8411 | 1788 | // ApplyAgentPromptConfigAtWebSearchGeneration additionally rejects a registry | |
| 8412 | 1789 | // resolved before a web-search policy transition. This final check is made in | |
| 8413 | 1790 | // the same critical section as live-agent replacement and the core prompt swap. | |
| 8414 | - | func (i *Interactive) ApplyAgentPromptConfigAtWebSearchGeneration(ag *core.Agent, system string, tools core.Registry, generation uint64) (core.Registry, bool) { | |
| 8415 | - | return i.applyAgentPromptConfig(ag, system, tools, generation, true) | |
| 8416 | - | } | |
| 8417 | - | ||
| 8418 | - | func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry, generation uint64, checkGeneration bool) (core.Registry, bool) { | |
| 8419 | - | if ag == nil { | |
| 8420 | - | return nil, false | |
| 8421 | - | } | |
| 8422 | - | // Match the lock order used by dynamic tool mutations so a refresh | |
| 8423 | - | // cannot race a snapshot-and-replace operation that would reintroduce | |
| 8424 | - | // the old registry after the atomic prompt swap. | |
| 8425 | - | i.agentMu.Lock() | |
| 8426 | - | defer i.agentMu.Unlock() | |
| 8427 | - | i.mu.Lock() | |
| 8428 | - | defer i.mu.Unlock() | |
| 8429 | - | if i.agent != ag || checkGeneration && i.webSearchPolicyGeneration.Load() != generation { | |
| 8430 | - | return nil, false | |
| 8431 | - | } | |
| 8432 | - | if i.telegramBridge != nil { | |
| 8433 | - | // Telegram prompts arrive over an external messaging channel without a | |
| 8434 | - | // per-request confirmation surface. Keep the normal interactive | |
| 8435 | - | // registry from reintroducing web_search from the moment the bridge is | |
| 8436 | - | // attached, including while its startup handshake is in flight. | |
| 8437 | - | delete(tools, "web_search") | |
| 8438 | - | } | |
| 8439 | - | oldTools := ag.SetPromptConfig(system, tools) | |
| 8440 | - | _, webSearchAvailable := tools["web_search"] | |
| 8441 | - | i.setWebSearchAvailable(webSearchAvailable) | |
| 8442 | - | return oldTools, true | |
| 8443 | - | } | |
| 8444 | 1791 | ||
| 8445 | 1792 | // prepareReplacementAgentLocked applies session-wide capability policy before | |
| 8446 | 1793 | // ag becomes visible to prompt submission. The caller holds agentMu and i.mu. | |
| 8447 | - | func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) { | |
| 8448 | - | if ag == nil { | |
| 8449 | - | i.setWebSearchAvailable(false) | |
| 8450 | - | return | |
| 8451 | - | } | |
| 8452 | - | registry := ag.ToolsSnapshot() | |
| 8453 | - | if i.telegramBridge != nil { | |
| 8454 | - | // External Telegram prompts may arrive as soon as the replacement is | |
| 8455 | - | // published, so post-swap cleanup is too late. | |
| 8456 | - | delete(registry, "web_search") | |
| 8457 | - | ag.SetTools(registry) | |
| 8458 | - | } | |
| 8459 | - | _, webSearchAvailable := registry["web_search"] | |
| 8460 | - | i.setWebSearchAvailable(webSearchAvailable && i.telegramBridge == nil) | |
| 8461 | - | } | |
| 8462 | 1794 | ||
| 8463 | 1795 | // DeferUntilIdle runs work immediately when the interactive agent is idle, | |
| 8464 | 1796 | // or after the current turn/compaction/shell operation releases the busy | |
| 8465 | 1797 | // state. Callbacks run outside i.mu. | |
| 8466 | - | func (i *Interactive) DeferUntilIdle(fn func()) { | |
| 8467 | - | if fn == nil { | |
| 8468 | - | return | |
| 8469 | - | } | |
| 8470 | - | i.mu.Lock() | |
| 8471 | - | if i.busy { | |
| 8472 | - | i.pendingIdleWork = append(i.pendingIdleWork, fn) | |
| 8473 | - | i.mu.Unlock() | |
| 8474 | - | return | |
| 8475 | - | } | |
| 8476 | - | i.mu.Unlock() | |
| 8477 | - | fn() | |
| 8478 | - | } | |
| 8479 | - | ||
| 8480 | - | func (i *Interactive) takePendingIdleWorkLocked() []func() { | |
| 8481 | - | work := i.pendingIdleWork | |
| 8482 | - | i.pendingIdleWork = nil | |
| 8483 | - | return work | |
| 8484 | - | } | |
| 8485 | - | ||
| 8486 | - | func runPendingIdleWork(work []func()) { | |
| 8487 | - | for _, fn := range work { | |
| 8488 | - | if fn != nil { | |
| 8489 | - | fn() | |
| 8490 | - | } | |
| 8491 | - | } | |
| 8492 | - | } | |
| 8493 | 1798 | ||
| 8494 | 1799 | // Agent returns the current agent, if any. Used by cli.go to flush the | |
| 8495 | 1800 | // final transcript to the session file. | |
| 8496 | - | func (i *Interactive) Agent() *core.Agent { | |
| 8497 | - | i.mu.Lock() | |
| 8498 | - | defer i.mu.Unlock() | |
| 8499 | - | return i.agent | |
| 8500 | - | } | |
| 8501 | 1801 | ||
| 8502 | 1802 | // silence unused import in some build configs | |
| 8503 | 1803 | var _ = fmt.Sprintf | |
| @@ -8506,28 +1806,6 @@ var _ = fmt.Sprintf | |||
| 8506 | 1806 | // + explicit). Runs on a goroutine so the TUI stays responsive; the | |
| 8507 | 1807 | // Manager.Reload takes a couple of hundred ms to shut down subprocs | |
| 8508 | 1808 | // and respawn them. Shows a temporary status line throughout. | |
| 8509 | - | func (i *Interactive) runReloadExt(ctx context.Context) { | |
| 8510 | - | if i.cfg.Extensions == nil { | |
| 8511 | - | i.mu.Lock() | |
| 8512 | - | i.statusErr = "no extension manager in this build" | |
| 8513 | - | i.mu.Unlock() | |
| 8514 | - | i.invalidate() | |
| 8515 | - | return | |
| 8516 | - | } | |
| 8517 | - | i.mu.Lock() | |
| 8518 | - | i.statusOK = "Reloading extensions..." | |
| 8519 | - | i.statusErr = "" | |
| 8520 | - | i.mu.Unlock() | |
| 8521 | - | i.invalidate() | |
| 8522 | - | ||
| 8523 | - | go func() { | |
| 8524 | - | stats := i.cfg.Extensions.Reload(ctx, 2*time.Second) | |
| 8525 | - | msg, failed := formatReloadStatus(stats) | |
| 8526 | - | seq := i.setReloadStatus(msg, failed) | |
| 8527 | - | i.invalidate() | |
| 8528 | - | go i.dismissReloadStatus(ctx, seq, msg, failed) | |
| 8529 | - | }() | |
| 8530 | - | } | |
| 8531 | 1809 | ||
| 8532 | 1810 | // Confirm implements core.Confirmer. The agent goroutine calls | |
| 8533 | 1811 | // this synchronously before every tool invocation when --no-yolo is | |
| @@ -8537,239 +1815,29 @@ func (i *Interactive) runReloadExt(ctx context.Context) { | |||
| 8537 | 1815 | // If the session is cancelled or the TUI exits mid-prompt, any | |
| 8538 | 1816 | // pending request is refused via CancelAll so the agent doesn't | |
| 8539 | 1817 | // deadlock. | |
| 8540 | - | func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision { | |
| 8541 | - | return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview}) | |
| 8542 | - | } | |
| 8543 | 1818 | ||
| 8544 | 1819 | // ConfirmToolCall attaches a side-effect-free preview to the matching live | |
| 8545 | 1820 | // tool panel, then blocks until the user approves or refuses the call. | |
| 8546 | - | func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision { | |
| 8547 | - | resp := make(chan core.ConfirmDecision, 1) | |
| 8548 | - | if isBtwOrigin(call.Origin) { | |
| 8549 | - | req := &confirmRequest{ | |
| 8550 | - | toolName: call.Name, | |
| 8551 | - | preview: call.Summary, | |
| 8552 | - | resp: resp, | |
| 8553 | - | returnToChild: true, | |
| 8554 | - | } | |
| 8555 | - | if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() { | |
| 8556 | - | i.confirmDialog.Enqueue(req) | |
| 8557 | - | }) { | |
| 8558 | - | return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"} | |
| 8559 | - | } | |
| 8560 | - | i.invalidate() | |
| 8561 | - | return <-resp | |
| 8562 | - | } | |
| 8563 | - | if call.ID != "" { | |
| 8564 | - | i.mu.Lock() | |
| 8565 | - | if tc, ok := i.toolCalls[call.ID]; ok { | |
| 8566 | - | tc.Args = call.Summary | |
| 8567 | - | tc.Preview = call.Content | |
| 8568 | - | i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model} | |
| 8569 | - | } | |
| 8570 | - | i.mu.Unlock() | |
| 8571 | - | } | |
| 8572 | - | i.confirmDialog.Enqueue(&confirmRequest{ | |
| 8573 | - | toolName: call.Name, | |
| 8574 | - | preview: call.Summary, | |
| 8575 | - | resp: resp, | |
| 8576 | - | }) | |
| 8577 | - | i.invalidate() | |
| 8578 | - | return <-resp | |
| 8579 | - | } | |
| 8580 | 1821 | ||
| 8581 | 1822 | // openTelegramDialog shows the picker for `/telegram` with no arg. | |
| 8582 | 1823 | // Items depend on current state: disconnect + status when running, | |
| 8583 | 1824 | // connect + status when stopped. | |
| 8584 | - | func (i *Interactive) openTelegramDialog() { | |
| 8585 | - | items := i.telegramMenuItems() | |
| 8586 | - | if len(items) == 0 { | |
| 8587 | - | i.mu.Lock() | |
| 8588 | - | i.statusErr = "telegram not configured. run `zut telegram-bot setup` first." | |
| 8589 | - | i.mu.Unlock() | |
| 8590 | - | i.invalidate() | |
| 8591 | - | return | |
| 8592 | - | } | |
| 8593 | - | i.telegramDialog.Open(items) | |
| 8594 | - | i.invalidate() | |
| 8595 | - | } | |
| 8596 | 1825 | ||
| 8597 | 1826 | // telegramMenuItems builds the dialog entries for the current | |
| 8598 | 1827 | // bridge state. Returns empty when no bot.json exists so the | |
| 8599 | 1828 | // caller can show a helpful status line instead of an empty menu. | |
| 8600 | - | func (i *Interactive) telegramMenuItems() []telegramItem { | |
| 8601 | - | cfg, err := telegram.LoadConfig(i.cfg.ZutHome) | |
| 8602 | - | if err != nil || cfg.BotToken == "" { | |
| 8603 | - | return nil | |
| 8604 | - | } | |
| 8605 | - | var items []telegramItem | |
| 8606 | - | if i.telegramBridge != nil && i.telegramBridge.Active() { | |
| 8607 | - | items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"}) | |
| 8608 | - | st := i.telegramBridge.State() | |
| 8609 | - | hint := "active" | |
| 8610 | - | if st.Username != "" { | |
| 8611 | - | hint += " as @" + st.Username | |
| 8612 | - | } | |
| 8613 | - | items = append(items, telegramItem{label: "status", action: "status", hint: hint}) | |
| 8614 | - | } else { | |
| 8615 | - | label := "connect" | |
| 8616 | - | hint := "start mirroring dms into this session" | |
| 8617 | - | if cfg.AllowedUserID == 0 { | |
| 8618 | - | hint = "awaiting pairing (send /start to the bot once connected)" | |
| 8619 | - | } | |
| 8620 | - | items = append(items, telegramItem{label: label, action: "connect", hint: hint}) | |
| 8621 | - | items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"}) | |
| 8622 | - | } | |
| 8623 | - | return items | |
| 8624 | - | } | |
| 8625 | 1829 | ||
| 8626 | 1830 | // doTelegram dispatches one of the three explicit actions. Called | |
| 8627 | 1831 | // from /telegram <action> or after the picker selects a row. | |
| 8628 | - | func (i *Interactive) doTelegram(action string) { | |
| 8629 | - | switch action { | |
| 8630 | - | case "connect": | |
| 8631 | - | i.telegramConnect() | |
| 8632 | - | case "disconnect": | |
| 8633 | - | i.telegramDisconnect() | |
| 8634 | - | case "status": | |
| 8635 | - | i.telegramStatus() | |
| 8636 | - | default: | |
| 8637 | - | i.mu.Lock() | |
| 8638 | - | i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)" | |
| 8639 | - | i.mu.Unlock() | |
| 8640 | - | i.invalidate() | |
| 8641 | - | } | |
| 8642 | - | } | |
| 8643 | 1832 | ||
| 8644 | 1833 | // telegramConnect starts the bridge. Refuses if it's already | |
| 8645 | 1834 | // running or if the on-disk bot.json is missing a token. | |
| 8646 | - | func (i *Interactive) telegramConnect() { | |
| 8647 | - | if i.telegramBridge != nil && i.telegramBridge.Active() { | |
| 8648 | - | i.mu.Lock() | |
| 8649 | - | i.statusOK = "telegram already connected" | |
| 8650 | - | i.statusErr = "" | |
| 8651 | - | i.mu.Unlock() | |
| 8652 | - | i.invalidate() | |
| 8653 | - | return | |
| 8654 | - | } | |
| 8655 | - | cfg, err := telegram.LoadConfig(i.cfg.ZutHome) | |
| 8656 | - | if err != nil { | |
| 8657 | - | i.mu.Lock() | |
| 8658 | - | i.statusErr = "telegram: " + err.Error() | |
| 8659 | - | i.mu.Unlock() | |
| 8660 | - | i.invalidate() | |
| 8661 | - | return | |
| 8662 | - | } | |
| 8663 | - | if cfg.BotToken == "" { | |
| 8664 | - | i.mu.Lock() | |
| 8665 | - | i.statusErr = "telegram: no bot token configured. run `zut telegram-bot setup` first." | |
| 8666 | - | i.mu.Unlock() | |
| 8667 | - | i.invalidate() | |
| 8668 | - | return | |
| 8669 | - | } | |
| 8670 | - | // Refuse to start when a background daemon is already polling | |
| 8671 | - | // the same bot. Two concurrent long-poll consumers race each | |
| 8672 | - | // update and one always loses, so DMs get half-delivered. The | |
| 8673 | - | // user can `zut telegram-bot stop` first, then /telegram connect. | |
| 8674 | - | if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 { | |
| 8675 | - | i.mu.Lock() | |
| 8676 | - | i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zut telegram-bot stop` first.", pid) | |
| 8677 | - | i.mu.Unlock() | |
| 8678 | - | i.invalidate() | |
| 8679 | - | return | |
| 8680 | - | } | |
| 8681 | - | bridge := &telegram.Bridge{ | |
| 8682 | - | Client: telegram.NewClient(cfg.BotToken), | |
| 8683 | - | Config: cfg, | |
| 8684 | - | Save: func(next telegram.Config) error { | |
| 8685 | - | return telegram.SaveConfig(i.cfg.ZutHome, next) | |
| 8686 | - | }, | |
| 8687 | - | Host: &telegramHost{iv: i}, | |
| 8688 | - | } | |
| 8689 | - | i.mu.Lock() | |
| 8690 | - | i.telegramBridge = bridge | |
| 8691 | - | i.mu.Unlock() | |
| 8692 | - | // Strip web_search before the bridge's startup handshake can accept a | |
| 8693 | - | // Telegram update. ApplyAgentPromptConfig also keeps it stripped from | |
| 8694 | - | // concurrent refreshes while this bridge pointer is attached. | |
| 8695 | - | i.applyTelegramTools(true) | |
| 8696 | - | if err := bridge.Start(i.runCtx); err != nil { | |
| 8697 | - | i.mu.Lock() | |
| 8698 | - | i.telegramBridge = nil | |
| 8699 | - | i.mu.Unlock() | |
| 8700 | - | refreshErr := i.refreshToolsAfterTelegram() | |
| 8701 | - | i.mu.Lock() | |
| 8702 | - | i.statusErr = "telegram connect failed: " + err.Error() | |
| 8703 | - | if refreshErr != nil { | |
| 8704 | - | i.statusErr += "; tool refresh: " + refreshErr.Error() | |
| 8705 | - | } | |
| 8706 | - | i.mu.Unlock() | |
| 8707 | - | i.invalidate() | |
| 8708 | - | return | |
| 8709 | - | } | |
| 8710 | - | state := bridge.State() | |
| 8711 | - | label := "telegram connected" | |
| 8712 | - | if state.Username != "" { | |
| 8713 | - | label += " as @" + state.Username | |
| 8714 | - | } | |
| 8715 | - | if state.PairedID == 0 { | |
| 8716 | - | label += " — send /start to the bot from your phone to claim it" | |
| 8717 | - | } | |
| 8718 | - | i.mu.Lock() | |
| 8719 | - | i.statusOK = label | |
| 8720 | - | i.statusErr = "" | |
| 8721 | - | i.mu.Unlock() | |
| 8722 | - | i.invalidate() | |
| 8723 | - | } | |
| 8724 | 1835 | ||
| 8725 | 1836 | // telegramDisconnect stops the bridge. No-op when already stopped. | |
| 8726 | - | func (i *Interactive) telegramDisconnect() { | |
| 8727 | - | if i.telegramBridge == nil || !i.telegramBridge.Active() { | |
| 8728 | - | i.mu.Lock() | |
| 8729 | - | i.statusOK = "telegram already disconnected" | |
| 8730 | - | i.statusErr = "" | |
| 8731 | - | i.mu.Unlock() | |
| 8732 | - | i.invalidate() | |
| 8733 | - | return | |
| 8734 | - | } | |
| 8735 | - | bridge := i.telegramBridge | |
| 8736 | - | bridge.Stop() | |
| 8737 | - | i.mu.Lock() | |
| 8738 | - | // Clear the pointer before rebuilding the normal registry. This both | |
| 8739 | - | // allows web_search back into the refresh and marks the bridge inactive | |
| 8740 | - | // for any concurrent prompt-config commit. | |
| 8741 | - | i.telegramBridge = nil | |
| 8742 | - | i.mu.Unlock() | |
| 8743 | - | refreshErr := i.refreshToolsAfterTelegram() | |
| 8744 | - | i.mu.Lock() | |
| 8745 | - | if refreshErr != nil { | |
| 8746 | - | i.statusOK = "" | |
| 8747 | - | i.statusErr = "telegram disconnect tool refresh: " + refreshErr.Error() | |
| 8748 | - | } else { | |
| 8749 | - | i.statusOK = "telegram disconnected" | |
| 8750 | - | i.statusErr = "" | |
| 8751 | - | } | |
| 8752 | - | i.mu.Unlock() | |
| 8753 | - | i.invalidate() | |
| 8754 | - | } | |
| 8755 | 1837 | ||
| 8756 | 1838 | // refreshToolsAfterTelegram restores normal tools only through a successful | |
| 8757 | 1839 | // resolver refresh. Failure removes Telegram-only tools but leaves web_search | |
| 8758 | 1840 | // revoked in the live registry, stale snapshots, and generic-child policy. | |
| 8759 | - | func (i *Interactive) refreshToolsAfterTelegram() error { | |
| 8760 | - | if i.cfg.RefreshTools == nil { | |
| 8761 | - | i.stripWebSearchTool() | |
| 8762 | - | i.applyTelegramTools(false) | |
| 8763 | - | return errors.New("live tool refresh is unavailable") | |
| 8764 | - | } | |
| 8765 | - | if err := i.cfg.RefreshTools(); err != nil { | |
| 8766 | - | i.stripWebSearchTool() | |
| 8767 | - | i.applyTelegramTools(false) | |
| 8768 | - | return err | |
| 8769 | - | } | |
| 8770 | - | i.applyTelegramTools(false) | |
| 8771 | - | return nil | |
| 8772 | - | } | |
| 8773 | 1841 | ||
| 8774 | 1842 | // telegramSenderAdapter wraps the bridge so the tools package can | |
| 8775 | 1843 | // drive it without importing telegram directly. The Active() check | |
| @@ -8779,546 +1847,44 @@ type telegramSenderAdapter struct { | |||
| 8779 | 1847 | bridge *telegram.Bridge | |
| 8780 | 1848 | } | |
| 8781 | 1849 | ||
| 8782 | - | func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error { | |
| 8783 | - | if a.bridge == nil { | |
| 8784 | - | return fmt.Errorf("telegram bridge is not connected") | |
| 8785 | - | } | |
| 8786 | - | return a.bridge.SendImage(ctx, path, caption) | |
| 8787 | - | } | |
| 8788 | - | ||
| 8789 | - | func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error { | |
| 8790 | - | if a.bridge == nil { | |
| 8791 | - | return fmt.Errorf("telegram bridge is not connected") | |
| 8792 | - | } | |
| 8793 | - | return a.bridge.SendDocument(ctx, path, caption) | |
| 8794 | - | } | |
| 8795 | - | ||
| 8796 | - | func (a telegramSenderAdapter) Active() bool { | |
| 8797 | - | return a.bridge != nil && a.bridge.Active() | |
| 8798 | - | } | |
| 8799 | - | ||
| 8800 | 1850 | // TrackResidentSubagent reserves completion delivery after the manager has | |
| 8801 | 1851 | // durably accepted a resident child prompt and before it is scheduled. | |
| 8802 | - | func (i *Interactive) TrackResidentSubagent(childID string) { | |
| 8803 | - | if i == nil { | |
| 8804 | - | return | |
| 8805 | - | } | |
| 8806 | - | i.ensureCompletionTracker().TrackResident() | |
| 8807 | - | i.registerCoordinatorWorker(childID) | |
| 8808 | - | i.invalidate() | |
| 8809 | - | i.requestCompletionDelivery() | |
| 8810 | - | } | |
| 8811 | 1852 | ||
| 8812 | 1853 | // ReportResidentSubagent delivers a typed resident terminal outcome through | |
| 8813 | 1854 | // the same coordinator path used by legacy worker completions. | |
| 8814 | - | func (i *Interactive) ReportResidentSubagent(completion subagents.ResidentCompletion) { | |
| 8815 | - | if i == nil { | |
| 8816 | - | return | |
| 8817 | - | } | |
| 8818 | - | status, errText := "completed", "" | |
| 8819 | - | if completion.Err != nil { | |
| 8820 | - | status, errText = "failed", completion.Err.Error() | |
| 8821 | - | } | |
| 8822 | - | i.ensureCompletionTracker().Report(subagents.Completion{AgentID: completion.ChildID, Status: status, Task: completion.Task, Error: errText, Summary: completion.Summary}) | |
| 8823 | - | i.reloadOpenResidentChildSession(completion.ChildID) | |
| 8824 | - | i.invalidate() | |
| 8825 | - | i.requestCompletionDelivery() | |
| 8826 | - | } | |
| 8827 | - | ||
| 8828 | - | func (i *Interactive) reloadOpenResidentChildSession(childID string) { | |
| 8829 | - | if i == nil { | |
| 8830 | - | return | |
| 8831 | - | } | |
| 8832 | - | i.mu.Lock() | |
| 8833 | - | session := i.residentChildSession | |
| 8834 | - | i.mu.Unlock() | |
| 8835 | - | if session == nil || session.childID != childID || !session.RequestRecentReload() { | |
| 8836 | - | return | |
| 8837 | - | } | |
| 8838 | - | i.reloadResidentChildSession(session) | |
| 8839 | - | } | |
| 8840 | - | ||
| 8841 | - | func (i *Interactive) reloadResidentChildSession(session *residentChildSession) { | |
| 8842 | - | if i == nil || session == nil { | |
| 8843 | - | return | |
| 8844 | - | } | |
| 8845 | - | go func() { | |
| 8846 | - | err := session.ReloadRecent(200) | |
| 8847 | - | if session.FinishLoad(err) { | |
| 8848 | - | if session.RequestRecentReload() { | |
| 8849 | - | i.reloadResidentChildSession(session) | |
| 8850 | - | } | |
| 8851 | - | } | |
| 8852 | - | i.invalidate() | |
| 8853 | - | }() | |
| 8854 | - | } | |
| 8855 | - | ||
| 8856 | - | func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker { | |
| 8857 | - | // Tests and lightweight embedders may construct Interactive with a struct | |
| 8858 | - | // literal instead of NewInteractive, so retain this lazy fallback. | |
| 8859 | - | i.completionDeliveryMu.Lock() | |
| 8860 | - | defer i.completionDeliveryMu.Unlock() | |
| 8861 | - | if i.completionTracker == nil { | |
| 8862 | - | i.completionTracker = subagents.NewCompletionTracker() | |
| 8863 | - | } | |
| 8864 | - | return i.completionTracker | |
| 8865 | - | } | |
| 8866 | - | ||
| 8867 | - | func (i *Interactive) ensureCoordinatorLocked() *orchestration.Coordinator { | |
| 8868 | - | if i.turnCoordinator == nil { | |
| 8869 | - | i.turnCoordinator = orchestration.New() | |
| 8870 | - | } | |
| 8871 | - | return i.turnCoordinator | |
| 8872 | - | } | |
| 8873 | - | ||
| 8874 | - | func (i *Interactive) coordinatorAcceptsUserInput() bool { | |
| 8875 | - | if i == nil { | |
| 8876 | - | return false | |
| 8877 | - | } | |
| 8878 | - | i.completionDeliveryMu.Lock() | |
| 8879 | - | accepts := i.ensureCoordinatorLocked().AcceptsUserInput() | |
| 8880 | - | i.completionDeliveryMu.Unlock() | |
| 8881 | - | return accepts | |
| 8882 | - | } | |
| 8883 | - | ||
| 8884 | - | func (i *Interactive) coordinatorHasPendingWorkers() bool { | |
| 8885 | - | if i == nil { | |
| 8886 | - | return false | |
| 8887 | - | } | |
| 8888 | - | i.completionDeliveryMu.Lock() | |
| 8889 | - | pending := i.ensureCoordinatorLocked().HasPendingWorkers() | |
| 8890 | - | i.completionDeliveryMu.Unlock() | |
| 8891 | - | return pending | |
| 8892 | - | } | |
| 8893 | - | ||
| 8894 | - | func (i *Interactive) applyCoordinator(event orchestration.Event) []orchestration.Action { | |
| 8895 | - | if i == nil { | |
| 8896 | - | return nil | |
| 8897 | - | } | |
| 8898 | - | i.completionDeliveryMu.Lock() | |
| 8899 | - | result := i.ensureCoordinatorLocked().Apply(event) | |
| 8900 | - | i.completionDeliveryMu.Unlock() | |
| 8901 | - | return result.Actions | |
| 8902 | - | } | |
| 8903 | 1855 | ||
| 8904 | 1856 | // registerCoordinatorWorker associates a tracker registration with the open | |
| 8905 | 1857 | // manager wave. Direct embedders that register a worker outside startTurn get | |
| 8906 | - | // a one-worker sealed wave, retaining the exported tracker API's old behavior. | |
| 8907 | - | func (i *Interactive) registerCoordinatorWorker(workerID string) { | |
| 8908 | - | if i == nil || workerID == "" { | |
| 8909 | - | return | |
| 8910 | - | } | |
| 8911 | - | i.completionDeliveryMu.Lock() | |
| 8912 | - | coordinator := i.ensureCoordinatorLocked() | |
| 8913 | - | implicitWave := i.completionDeliveryHolds == 0 | |
| 8914 | - | if implicitWave { | |
| 8915 | - | coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerStarted}) | |
| 8916 | - | } | |
| 8917 | - | i.coordinatorWorkerSeq++ | |
| 8918 | - | registrationID := fmt.Sprintf("%s#%d", workerID, i.coordinatorWorkerSeq) | |
| 8919 | - | if i.coordinatorWorkerIDs == nil { | |
| 8920 | - | i.coordinatorWorkerIDs = make(map[string][]string) | |
| 8921 | - | } | |
| 8922 | - | i.coordinatorWorkerIDs[workerID] = append(i.coordinatorWorkerIDs[workerID], registrationID) | |
| 8923 | - | coordinator.Apply(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: registrationID}) | |
| 8924 | - | var actions []orchestration.Action | |
| 8925 | - | if implicitWave { | |
| 8926 | - | actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions | |
| 8927 | - | } | |
| 8928 | - | i.completionDeliveryMu.Unlock() | |
| 8929 | - | i.executeCoordinatorActions(actions) | |
| 8930 | - | } | |
| 8931 | - | ||
| 8932 | - | func (i *Interactive) cancelCoordinator() { | |
| 8933 | - | if i == nil { | |
| 8934 | - | return | |
| 8935 | - | } | |
| 8936 | - | i.completionDeliveryMu.Lock() | |
| 8937 | - | coordinator := i.ensureCoordinatorLocked() | |
| 8938 | - | actions := coordinator.Apply(orchestration.Event{Kind: orchestration.EventCancelled}).Actions | |
| 8939 | - | // A later user turn starts a fresh wave. Drop resident registrations as | |
| 8940 | - | // well as coordinator identities so late outcomes cannot match a new child. | |
| 8941 | - | if i.completionTracker != nil { | |
| 8942 | - | i.completionTracker.Reset() | |
| 8943 | - | } | |
| 8944 | - | i.turnCoordinator = orchestration.New() | |
| 8945 | - | i.coordinatorWorkerIDs = nil | |
| 8946 | - | i.completionDeliveryMu.Unlock() | |
| 8947 | - | i.executeCoordinatorActions(actions) | |
| 8948 | - | } | |
| 8949 | - | ||
| 8950 | - | func (i *Interactive) takeCoordinatorWorkerID(agentID string) string { | |
| 8951 | - | if i == nil || agentID == "" { | |
| 8952 | - | return "" | |
| 8953 | - | } | |
| 8954 | - | i.completionDeliveryMu.Lock() | |
| 8955 | - | defer i.completionDeliveryMu.Unlock() | |
| 8956 | - | ids := i.coordinatorWorkerIDs[agentID] | |
| 8957 | - | if len(ids) == 0 { | |
| 8958 | - | return "" | |
| 8959 | - | } | |
| 8960 | - | workerID := ids[0] | |
| 8961 | - | if len(ids) == 1 { | |
| 8962 | - | delete(i.coordinatorWorkerIDs, agentID) | |
| 8963 | - | } else { | |
| 8964 | - | i.coordinatorWorkerIDs[agentID] = ids[1:] | |
| 8965 | - | } | |
| 8966 | - | return workerID | |
| 8967 | - | } | |
| 8968 | - | ||
| 8969 | - | func (i *Interactive) executeCoordinatorActions(actions []orchestration.Action) { | |
| 8970 | - | for _, action := range actions { | |
| 8971 | - | if action.Kind != orchestration.ActionRunManager { | |
| 8972 | - | continue | |
| 8973 | - | } | |
| 8974 | - | prompt := action.Text | |
| 8975 | - | if len(action.Completions) != 0 { | |
| 8976 | - | instruction := "Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks." | |
| 8977 | - | update := subagents.FormatCompletionUpdate(action.Completions, instruction) | |
| 8978 | - | if prompt != "" { | |
| 8979 | - | prompt = update + "\n\nQueued user request:\n" + prompt | |
| 8980 | - | } else { | |
| 8981 | - | prompt = update | |
| 8982 | - | } | |
| 8983 | - | } | |
| 8984 | - | if prompt != "" { | |
| 8985 | - | i.submitOrQueue(prompt, nil, false) | |
| 8986 | - | } else if action.Reason == orchestration.WakeGoal { | |
| 8987 | - | parent := i.runCtx | |
| 8988 | - | if parent == nil { | |
| 8989 | - | parent = context.Background() | |
| 8990 | - | } | |
| 8991 | - | i.requestGoalContinuationIfIdle(parent) | |
| 8992 | - | } | |
| 8993 | - | } | |
| 8994 | - | } | |
| 8995 | - | ||
| 8996 | - | // requestCompletionDelivery starts at most one waiter for the current active | |
| 8997 | - | // set. A request arriving while that waiter is formatting or submitting is | |
| 8998 | - | // picked up before the waiter exits, so a later worker cannot be lost in the | |
| 8999 | - | // handoff between batches. | |
| 9000 | - | func (i *Interactive) requestCompletionDelivery() { | |
| 9001 | - | if i == nil { | |
| 9002 | - | return | |
| 9003 | - | } | |
| 9004 | - | i.ensureCompletionTracker() | |
| 9005 | - | i.completionDeliveryMu.Lock() | |
| 9006 | - | i.completionDeliveryRequest = true | |
| 9007 | - | if i.completionDeliveryRunning || i.completionDeliveryHolds != 0 { | |
| 9008 | - | i.completionDeliveryMu.Unlock() | |
| 9009 | - | return | |
| 9010 | - | } | |
| 9011 | - | i.completionDeliveryRunning = true | |
| 9012 | - | i.completionDeliveryMu.Unlock() | |
| 9013 | - | ||
| 9014 | - | go i.deliverCompletionUpdates() | |
| 9015 | - | } | |
| 9016 | - | ||
| 9017 | - | // beginCompletionDeliveryHold keeps all completions observed during one | |
| 9018 | - | // parent model turn together. Tool calls are executed sequentially inside the | |
| 9019 | - | // core agent, so the release point is the owning registration boundary for | |
| 9020 | - | // same-parent-turn batching rather than a timing-based debounce. | |
| 9021 | - | func (i *Interactive) beginCompletionDeliveryHold() func() { | |
| 9022 | - | if i == nil { | |
| 9023 | - | return func() {} | |
| 9024 | - | } | |
| 9025 | - | i.completionDeliveryMu.Lock() | |
| 9026 | - | i.ensureCoordinatorLocked().Apply(orchestration.Event{Kind: orchestration.EventManagerStarted}) | |
| 9027 | - | i.completionDeliveryHolds++ | |
| 9028 | - | i.completionDeliveryMu.Unlock() | |
| 9029 | - | return i.releaseCompletionDeliveryHold | |
| 9030 | - | } | |
| 9031 | - | ||
| 9032 | - | func (i *Interactive) releaseCompletionDeliveryHold() { | |
| 9033 | - | if i == nil { | |
| 9034 | - | return | |
| 9035 | - | } | |
| 9036 | - | start := false | |
| 9037 | - | var actions []orchestration.Action | |
| 9038 | - | i.completionDeliveryMu.Lock() | |
| 9039 | - | if i.completionDeliveryHolds > 0 { | |
| 9040 | - | i.completionDeliveryHolds-- | |
| 9041 | - | } | |
| 9042 | - | if i.completionDeliveryHolds == 0 { | |
| 9043 | - | // The coordinator must observe the goal before sealing the wave, so a | |
| 9044 | - | // pending worker wins the wake decision instead of a direct continuation. | |
| 9045 | - | _, goalActive := i.goalContinuationMessage() | |
| 9046 | - | coordinator := i.ensureCoordinatorLocked() | |
| 9047 | - | coordinator.Apply(orchestration.Event{Kind: orchestration.EventGoalChanged, GoalActive: goalActive}) | |
| 9048 | - | actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions | |
| 9049 | - | } | |
| 9050 | - | if i.completionDeliveryHolds == 0 && i.completionDeliveryRequest && !i.completionDeliveryRunning { | |
| 9051 | - | i.completionDeliveryRunning = true | |
| 9052 | - | start = true | |
| 9053 | - | } | |
| 9054 | - | i.completionDeliveryMu.Unlock() | |
| 9055 | - | i.executeCoordinatorActions(actions) | |
| 9056 | - | if start { | |
| 9057 | - | go i.deliverCompletionUpdates() | |
| 9058 | - | } | |
| 9059 | - | } | |
| 9060 | - | ||
| 9061 | - | func (i *Interactive) completionWaitContext() context.Context { | |
| 9062 | - | i.mu.Lock() | |
| 9063 | - | ctx := i.runCtx | |
| 9064 | - | i.mu.Unlock() | |
| 9065 | - | if ctx == nil { | |
| 9066 | - | return context.Background() | |
| 9067 | - | } | |
| 9068 | - | return ctx | |
| 9069 | - | } | |
| 9070 | - | ||
| 9071 | - | func (i *Interactive) deliverCompletionUpdates() { | |
| 9072 | - | for { | |
| 9073 | - | i.completionDeliveryMu.Lock() | |
| 9074 | - | i.completionDeliveryRequest = false | |
| 9075 | - | tracker := i.completionTracker | |
| 9076 | - | i.completionDeliveryMu.Unlock() | |
| 9077 | - | ||
| 9078 | - | batch, err := tracker.WaitIdle(i.completionWaitContext()) | |
| 9079 | - | if err == nil && len(batch) != 0 { | |
| 9080 | - | var actions []orchestration.Action | |
| 9081 | - | for _, completion := range batch { | |
| 9082 | - | workerID := i.takeCoordinatorWorkerID(completion.AgentID) | |
| 9083 | - | if workerID == "" { | |
| 9084 | - | continue | |
| 9085 | - | } | |
| 9086 | - | actions = append(actions, i.applyCoordinator(orchestration.Event{ | |
| 9087 | - | Kind: orchestration.EventWorkerFinished, | |
| 9088 | - | WorkerID: workerID, | |
| 9089 | - | Completion: completion, | |
| 9090 | - | })...) | |
| 9091 | - | } | |
| 9092 | - | i.executeCoordinatorActions(actions) | |
| 9093 | - | } | |
| 1858 | + | // a one-worker sealed wave, retaining the exported tracker API's old behavior. | |
| 9094 | 1859 | ||
| 9095 | - | i.completionDeliveryMu.Lock() | |
| 9096 | - | if i.completionDeliveryRequest { | |
| 9097 | - | i.completionDeliveryMu.Unlock() | |
| 9098 | - | continue | |
| 9099 | - | } | |
| 9100 | - | i.completionDeliveryRunning = false | |
| 9101 | - | i.completionDeliveryMu.Unlock() | |
| 9102 | - | return | |
| 9103 | - | } | |
| 9104 | - | } | |
| 1860 | + | // requestCompletionDelivery starts at most one waiter for the current active | |
| 1861 | + | // set. A request arriving while that waiter is formatting or submitting is | |
| 1862 | + | // picked up before the waiter exits, so a later worker cannot be lost in the | |
| 1863 | + | // handoff between batches. | |
| 1864 | + | ||
| 1865 | + | // beginCompletionDeliveryHold keeps all completions observed during one | |
| 1866 | + | // parent model turn together. Tool calls are executed sequentially inside the | |
| 1867 | + | // core agent, so the release point is the owning registration boundary for | |
| 1868 | + | // same-parent-turn batching rather than a timing-based debounce. | |
| 9105 | 1869 | ||
| 9106 | 1870 | // autoSubagentsAddenda returns the prompt blocks owned by subagent prompting, | |
| 9107 | 1871 | // in the same order Resolve appends them to a new agent. The profile manifest | |
| 9108 | 1872 | // is independent of strict orchestration and remains available in on-demand | |
| 9109 | 1873 | // mode whenever the primary agent can spawn a worker. | |
| 9110 | - | func autoSubagentsAddenda(cfg InteractiveConfig, orchestrating bool) []string { | |
| 9111 | - | addenda := make([]string, 0, 2) | |
| 9112 | - | if cfg.ResidentManager != nil && autoSubagentsToolAllowedConfig(cfg) { | |
| 9113 | - | if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" { | |
| 9114 | - | addenda = append(addenda, addendum) | |
| 9115 | - | } | |
| 9116 | - | } | |
| 9117 | - | if !orchestrating { | |
| 9118 | - | if cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(cfg) { | |
| 9119 | - | if addendum := strings.TrimSpace(cfg.OnDemandSubagentsSystemAddendum); addendum != "" { | |
| 9120 | - | addenda = append(addenda, addendum) | |
| 9121 | - | } | |
| 9122 | - | } | |
| 9123 | - | return addenda | |
| 9124 | - | } | |
| 9125 | - | ||
| 9126 | - | if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" { | |
| 9127 | - | addenda = append(addenda, addendum) | |
| 9128 | - | } | |
| 9129 | - | return addenda | |
| 9130 | - | } | |
| 9131 | 1874 | ||
| 9132 | 1875 | // removeLastAutoSubagentsAddendum removes one occurrence of a block known to | |
| 9133 | 1876 | // have been appended by auto-subagents. Resolve appends owned blocks at the | |
| 9134 | 1877 | // end, so removing the last occurrence preserves an identical base block. | |
| 9135 | - | func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) { | |
| 9136 | - | idx := strings.LastIndex(system, addendum) | |
| 9137 | - | if idx < 0 { | |
| 9138 | - | return system, false | |
| 9139 | - | } | |
| 9140 | - | return system[:idx] + system[idx+len(addendum):], true | |
| 9141 | - | } | |
| 9142 | 1878 | ||
| 9143 | 1879 | // applyAutoSubagentsSystemPrompt swaps the prompt blocks owned by subagent | |
| 9144 | 1880 | // prompting on the running agent. Toggling strict orchestration changes the | |
| 9145 | 1881 | // delegation guidance while retaining the profile manifest when usable. | |
| 9146 | - | func (i *Interactive) applyAutoSubagentsSystemPrompt(orchestrating bool) { | |
| 9147 | - | // i.mu is sufficient here: this path updates the existing agent's system | |
| 9148 | - | // prompt and managed addenda in place; it does not replace the agent or | |
| 9149 | - | // its tool registry, so agentMu is not required. | |
| 9150 | - | i.mu.Lock() | |
| 9151 | - | defer i.mu.Unlock() | |
| 9152 | - | if i.agent == nil { | |
| 9153 | - | return | |
| 9154 | - | } | |
| 9155 | - | ||
| 9156 | - | sys, _ := i.agent.PromptConfig() | |
| 9157 | - | changed := false | |
| 9158 | - | if guidance := strings.TrimSpace(i.cfg.WritingGuidance); guidance != "" { | |
| 9159 | - | var removed bool | |
| 9160 | - | sys, removed = removeLastAutoSubagentsAddendum(sys, guidance) | |
| 9161 | - | changed = changed || removed | |
| 9162 | - | } | |
| 9163 | - | for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- { | |
| 9164 | - | var removed bool | |
| 9165 | - | sys, removed = removeLastAutoSubagentsAddendum(sys, i.managedAutoSubagentsAddenda[idx]) | |
| 9166 | - | changed = changed || removed | |
| 9167 | - | } | |
| 9168 | - | i.managedAutoSubagentsAddenda = nil | |
| 9169 | - | ||
| 9170 | - | addenda := autoSubagentsAddenda(i.cfg, orchestrating) | |
| 9171 | - | if changed { | |
| 9172 | - | sys = strings.TrimRight(sys, "\n") | |
| 9173 | - | } | |
| 9174 | - | for _, addendum := range addenda { | |
| 9175 | - | if sys != "" { | |
| 9176 | - | sys += "\n\n" | |
| 9177 | - | } | |
| 9178 | - | sys += addendum | |
| 9179 | - | i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum) | |
| 9180 | - | changed = true | |
| 9181 | - | } | |
| 9182 | - | if guidance := strings.TrimSpace(i.cfg.WritingGuidance); guidance != "" { | |
| 9183 | - | if sys != "" { | |
| 9184 | - | sys += "\n\n" | |
| 9185 | - | } | |
| 9186 | - | sys += guidance | |
| 9187 | - | changed = true | |
| 9188 | - | } | |
| 9189 | - | if !changed { | |
| 9190 | - | return | |
| 9191 | - | } | |
| 9192 | - | i.agent.SetSystemPrompt(sys) | |
| 9193 | - | } | |
| 9194 | 1882 | ||
| 9195 | 1883 | // applyAutoSubagentsTool registers the canonical subagent tools whenever | |
| 9196 | 1884 | // launch-time policy permits them. Auto-subagents controls prompt behavior, | |
| 9197 | 1885 | // while the tools remain available for explicit user-requested delegation. | |
| 9198 | 1886 | // Mirrors applyTelegramTools' snapshot+mutate pattern so extension tools and | |
| 9199 | 1887 | // /reload-ext additions survive a settings change. | |
| 9200 | - | func (i *Interactive) autoSubagentsAvailable() bool { | |
| 9201 | - | return i.cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(i.cfg) | |
| 9202 | - | } | |
| 9203 | - | ||
| 9204 | - | func (i *Interactive) autoSubagentsUnavailableHint() string { | |
| 9205 | - | var hints []string | |
| 9206 | - | if i.cfg.ResidentManager == nil { | |
| 9207 | - | hints = append(hints, "resident subagent manager is unavailable in this mode") | |
| 9208 | - | } | |
| 9209 | - | if !autoSubagentsAnyToolAllowedConfig(i.cfg) { | |
| 9210 | - | hints = append(hints, "launch-time tool policy excludes subagent manager tools") | |
| 9211 | - | } | |
| 9212 | - | return strings.Join(hints, "; ") | |
| 9213 | - | } | |
| 9214 | - | ||
| 9215 | - | func autoSubagentsToolAllowedConfig(cfg InteractiveConfig) bool { | |
| 9216 | - | return cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed | |
| 9217 | - | } | |
| 9218 | - | ||
| 9219 | - | func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool { | |
| 9220 | - | if cfg.AutoSubagentsStatusToolAllowed != nil { | |
| 9221 | - | return *cfg.AutoSubagentsStatusToolAllowed | |
| 9222 | - | } | |
| 9223 | - | // Preserve the old single-boolean embedding contract: when callers have | |
| 9224 | - | // not supplied the new field, status follows the existing delegation gate. | |
| 9225 | - | return autoSubagentsToolAllowedConfig(cfg) | |
| 9226 | - | } | |
| 9227 | - | ||
| 9228 | - | func autoSubagentsStopToolAllowedConfig(cfg InteractiveConfig) bool { | |
| 9229 | - | if cfg.AutoSubagentsStopToolAllowed != nil { | |
| 9230 | - | return *cfg.AutoSubagentsStopToolAllowed | |
| 9231 | - | } | |
| 9232 | - | return autoSubagentsToolAllowedConfig(cfg) | |
| 9233 | - | } | |
| 9234 | - | ||
| 9235 | - | func autoSubagentsResumeToolAllowedConfig(cfg InteractiveConfig) bool { | |
| 9236 | - | if cfg.AutoSubagentsResumeToolAllowed != nil { | |
| 9237 | - | return *cfg.AutoSubagentsResumeToolAllowed | |
| 9238 | - | } | |
| 9239 | - | return autoSubagentsToolAllowedConfig(cfg) | |
| 9240 | - | } | |
| 9241 | - | ||
| 9242 | - | func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool { | |
| 9243 | - | return autoSubagentsToolAllowedConfig(cfg) || | |
| 9244 | - | autoSubagentsStatusToolAllowedConfig(cfg) || | |
| 9245 | - | autoSubagentsStopToolAllowedConfig(cfg) || | |
| 9246 | - | autoSubagentsResumeToolAllowedConfig(cfg) | |
| 9247 | - | } | |
| 9248 | - | ||
| 9249 | - | func (i *Interactive) autoSubagentsToolAllowed() bool { | |
| 9250 | - | return autoSubagentsToolAllowedConfig(i.cfg) | |
| 9251 | - | } | |
| 9252 | - | ||
| 9253 | - | func (i *Interactive) autoSubagentsStatusToolAllowed() bool { | |
| 9254 | - | return autoSubagentsStatusToolAllowedConfig(i.cfg) | |
| 9255 | - | } | |
| 9256 | - | ||
| 9257 | - | func (i *Interactive) autoSubagentsStopToolAllowed() bool { | |
| 9258 | - | return autoSubagentsStopToolAllowedConfig(i.cfg) | |
| 9259 | - | } | |
| 9260 | - | ||
| 9261 | - | func (i *Interactive) autoSubagentsResumeToolAllowed() bool { | |
| 9262 | - | return autoSubagentsResumeToolAllowedConfig(i.cfg) | |
| 9263 | - | } | |
| 9264 | - | ||
| 9265 | - | func (i *Interactive) autoSubagentsEnabledLocked() bool { | |
| 9266 | - | return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable() | |
| 9267 | - | } | |
| 9268 | - | ||
| 9269 | - | func (i *Interactive) applyAutoSubagentsTool() { | |
| 9270 | - | i.agentMu.Lock() | |
| 9271 | - | defer i.agentMu.Unlock() | |
| 9272 | - | i.mu.Lock() | |
| 9273 | - | defer i.mu.Unlock() | |
| 9274 | - | if i.agent == nil { | |
| 9275 | - | return | |
| 9276 | - | } | |
| 9277 | - | current := i.agent.ToolsSnapshot() | |
| 9278 | - | next := core.Registry{} | |
| 9279 | - | for name, t := range current { | |
| 9280 | - | if name == "subagent_spawn" || name == "subagent_status" || name == "subagent_stop" || name == "subagent_resume" { | |
| 9281 | - | continue | |
| 9282 | - | } | |
| 9283 | - | next[name] = t | |
| 9284 | - | } | |
| 9285 | - | if i.autoSubagentsAvailable() { | |
| 9286 | - | if i.autoSubagentsToolAllowed() { | |
| 9287 | - | canonical := &tools.SubagentSpawnTool{ | |
| 9288 | - | ResidentManager: i.cfg.ResidentManager, | |
| 9289 | - | BuildResidentSpec: i.cfg.BuildResidentSpec, | |
| 9290 | - | Enabled: func() bool { return true }, | |
| 9291 | - | DefaultModel: func() string { return i.cfg.Model }, | |
| 9292 | - | DefaultProvider: func() string { return i.cfg.Provider }, | |
| 9293 | - | DefaultReasoning: func() string { return i.cfg.Reasoning }, | |
| 9294 | - | ResolveSubagent: i.cfg.ResolveSubagent, | |
| 9295 | - | } | |
| 9296 | - | next[canonical.Name()] = canonical | |
| 9297 | - | } | |
| 9298 | - | if i.autoSubagentsStatusToolAllowed() { | |
| 9299 | - | statusTool := &tools.SubagentStatusTool{ | |
| 9300 | - | ResidentManager: i.cfg.ResidentManager, | |
| 9301 | - | Enabled: func() bool { return true }, | |
| 9302 | - | } | |
| 9303 | - | next[statusTool.Name()] = statusTool | |
| 9304 | - | } | |
| 9305 | - | if i.autoSubagentsStopToolAllowed() { | |
| 9306 | - | stopTool := &tools.SubagentStopTool{ | |
| 9307 | - | ResidentManager: i.cfg.ResidentManager, | |
| 9308 | - | Enabled: func() bool { return true }, | |
| 9309 | - | } | |
| 9310 | - | next[stopTool.Name()] = stopTool | |
| 9311 | - | } | |
| 9312 | - | if i.autoSubagentsResumeToolAllowed() { | |
| 9313 | - | resumeTool := &tools.SubagentResumeTool{ | |
| 9314 | - | ResidentManager: i.cfg.ResidentManager, | |
| 9315 | - | Enabled: func() bool { return true }, | |
| 9316 | - | } | |
| 9317 | - | next[resumeTool.Name()] = resumeTool | |
| 9318 | - | } | |
| 9319 | - | } | |
| 9320 | - | i.agent.SetTools(next) | |
| 9321 | - | } | |
| 9322 | 1888 | ||
| 9323 | 1889 | // applyTelegramTools registers (active=true) or removes (active=false) | |
| 9324 | 1890 | // the telegram_send_image and telegram_send_file tools on the running | |
| @@ -9327,644 +1893,90 @@ func (i *Interactive) applyAutoSubagentsTool() { | |||
| 9327 | 1893 | // /reload-ext additions made while Telegram is connected survive a | |
| 9328 | 1894 | // later /telegram disconnect. The two Telegram entries are always replaced; | |
| 9329 | 1895 | // web_search is additionally stripped only while the external bridge is active. | |
| 9330 | - | func (i *Interactive) applyTelegramTools(active bool) { | |
| 9331 | - | if active { | |
| 9332 | - | // The child ceiling must be in place even when no live main agent exists, | |
| 9333 | - | // and before Bridge.Start can accept an external prompt. | |
| 9334 | - | i.setWebSearchAvailable(false) | |
| 9335 | - | } | |
| 9336 | - | i.agentMu.Lock() | |
| 9337 | - | defer i.agentMu.Unlock() | |
| 9338 | - | i.mu.Lock() | |
| 9339 | - | defer i.mu.Unlock() | |
| 9340 | - | if i.agent == nil { | |
| 9341 | - | return | |
| 9342 | - | } | |
| 9343 | - | current := i.agent.ToolsSnapshot() | |
| 9344 | - | next := core.Registry{} | |
| 9345 | - | for name, t := range current { | |
| 9346 | - | if name == "telegram_send_image" || name == "telegram_send_file" { | |
| 9347 | - | continue | |
| 9348 | - | } | |
| 9349 | - | if active && name == "web_search" { | |
| 9350 | - | // External Telegram prompts have no per-request confirmation | |
| 9351 | - | // surface, so V1 does not expose native web search while paired. | |
| 9352 | - | continue | |
| 9353 | - | } | |
| 9354 | - | next[name] = t | |
| 9355 | - | } | |
| 9356 | - | if active { | |
| 9357 | - | sender := telegramSenderAdapter{bridge: i.telegramBridge} | |
| 9358 | - | next["telegram_send_image"] = &tools.TelegramSendImageTool{ | |
| 9359 | - | CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender, | |
| 9360 | - | } | |
| 9361 | - | next["telegram_send_file"] = &tools.TelegramSendFileTool{ | |
| 9362 | - | CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender, | |
| 9363 | - | } | |
| 9364 | - | } | |
| 9365 | - | i.agent.SetTools(next) | |
| 9366 | - | _, webSearchAvailable := next["web_search"] | |
| 9367 | - | i.setWebSearchAvailable(webSearchAvailable && !active) | |
| 9368 | - | } | |
| 9369 | 1896 | ||
| 9370 | 1897 | // telegramStatus writes a one-liner describing the bridge state. | |
| 9371 | 1898 | // Reports on both the in-tui bridge and the background daemon so | |
| 9372 | 1899 | // the user isn't confused when the daemon owns the poll loop. | |
| 9373 | - | func (i *Interactive) telegramStatus() { | |
| 9374 | - | var msg string | |
| 9375 | - | if i.telegramBridge != nil && i.telegramBridge.Active() { | |
| 9376 | - | s := i.telegramBridge.State() | |
| 9377 | - | msg = "telegram: connected (tui bridge)" | |
| 9378 | - | if s.Username != "" { | |
| 9379 | - | msg += " as @" + s.Username | |
| 9380 | - | } | |
| 9381 | - | if s.PairedID != 0 { | |
| 9382 | - | msg += fmt.Sprintf(" - paired with user %d", s.PairedID) | |
| 9383 | - | } else { | |
| 9384 | - | msg += " - awaiting pairing" | |
| 9385 | - | } | |
| 9386 | - | } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 { | |
| 9387 | - | msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid) | |
| 9388 | - | } else { | |
| 9389 | - | cfg, _ := telegram.LoadConfig(i.cfg.ZutHome) | |
| 9390 | - | if cfg.BotToken == "" { | |
| 9391 | - | msg = "telegram: not configured. run `zut telegram-bot setup` first." | |
| 9392 | - | } else { | |
| 9393 | - | msg = "telegram: disconnected" | |
| 9394 | - | if cfg.BotUsername != "" { | |
| 9395 | - | msg += " (@" + cfg.BotUsername + " ready to connect)" | |
| 9396 | - | } | |
| 9397 | - | } | |
| 9398 | - | } | |
| 9399 | - | i.mu.Lock() | |
| 9400 | - | i.statusOK = msg | |
| 9401 | - | i.statusErr = "" | |
| 9402 | - | i.mu.Unlock() | |
| 9403 | - | i.invalidate() | |
| 9404 | - | } | |
| 9405 | 1900 | ||
| 9406 | 1901 | // telegramHost adapts *Interactive to telegram.Host so the bridge | |
| 9407 | 1902 | // can call back into the TUI without importing modes directly. | |
| 9408 | 1903 | type telegramHost struct{ iv *Interactive } | |
| 9409 | 1904 | ||
| 9410 | - | func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) { | |
| 9411 | - | h.iv.SubmitOrQueue(prompt, images) | |
| 9412 | - | } | |
| 9413 | - | ||
| 9414 | - | func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() } | |
| 9415 | - | ||
| 9416 | - | func (h *telegramHost) Status() string { | |
| 9417 | - | h.iv.mu.Lock() | |
| 9418 | - | providerName := h.iv.cfg.Provider | |
| 9419 | - | model := h.iv.cfg.Model | |
| 9420 | - | cwd := h.iv.cfg.CWD | |
| 9421 | - | usage := h.iv.cumUsage | |
| 9422 | - | subscription := h.iv.cfg.AuthMethod == "oauth" | |
| 9423 | - | ctxUsed := h.iv.lastCtxInput | |
| 9424 | - | busy := h.iv.busy | |
| 9425 | - | queued := len(h.iv.queued) | |
| 9426 | - | h.iv.mu.Unlock() | |
| 9427 | - | ||
| 9428 | - | ctxMax := 0 | |
| 9429 | - | if m, err := provider.FindModel(providerName, model); err == nil { | |
| 9430 | - | ctxMax = m.ContextWindow | |
| 9431 | - | } | |
| 9432 | - | return telegram.FormatStatus(telegram.StatusSnapshot{ | |
| 9433 | - | Provider: providerName, | |
| 9434 | - | Model: model, | |
| 9435 | - | CWD: cwd, | |
| 9436 | - | Usage: usage, | |
| 9437 | - | Subscription: subscription, | |
| 9438 | - | ContextUsed: ctxUsed, | |
| 9439 | - | ContextMax: ctxMax, | |
| 9440 | - | Busy: busy, | |
| 9441 | - | Queued: queued, | |
| 9442 | - | }) | |
| 9443 | - | } | |
| 9444 | - | ||
| 9445 | - | func (h *telegramHost) Notify(level, message string) { | |
| 9446 | - | h.iv.mu.Lock() | |
| 9447 | - | switch level { | |
| 9448 | - | case "error", "warn": | |
| 9449 | - | h.iv.statusErr = message | |
| 9450 | - | h.iv.statusOK = "" | |
| 9451 | - | default: | |
| 9452 | - | h.iv.statusOK = message | |
| 9453 | - | h.iv.statusErr = "" | |
| 9454 | - | } | |
| 9455 | - | h.iv.mu.Unlock() | |
| 9456 | - | h.iv.invalidate() | |
| 9457 | - | } | |
| 9458 | - | ||
| 9459 | 1905 | // openSessionOpsDialog shows the picker for `/session` with no arg. | |
| 9460 | 1906 | // Always offers export, import, fork, tree; the handlers bail with | |
| 9461 | 1907 | // a clear status message when the precondition isn't met (empty | |
| 9462 | 1908 | // transcript on fork; no parent/siblings on tree). | |
| 9463 | - | func (i *Interactive) openSessionOpsDialog() { | |
| 9464 | - | items := []sessionOpsItem{ | |
| 9465 | - | {label: "export", action: "export", hint: "write the current session to a .zutsession file"}, | |
| 9466 | - | {label: "import", action: "import", hint: "load a .zutsession file into this directory"}, | |
| 9467 | - | {label: "fork", action: "fork", hint: "branch from a past user message into a new session"}, | |
| 9468 | - | {label: "tree", action: "tree", hint: "switch between branches in this directory"}, | |
| 9469 | - | } | |
| 9470 | - | i.sessionOpsDialog.Open(items) | |
| 9471 | - | i.invalidate() | |
| 9472 | - | } | |
| 9473 | 1909 | ||
| 9474 | 1910 | // doSessionOp dispatches export, import, fork, or tree. arg is the | |
| 9475 | 1911 | // optional positional argument from e.g. /session export <path> | |
| 9476 | 1912 | // or /session import <path>; fork and tree ignore it. | |
| 9477 | - | func (i *Interactive) doSessionOp(action, arg string) { | |
| 9478 | - | switch action { | |
| 9479 | - | case "export": | |
| 9480 | - | i.doSessionExport(arg) | |
| 9481 | - | case "import": | |
| 9482 | - | i.doSessionImport(arg) | |
| 9483 | - | case "fork": | |
| 9484 | - | i.doSessionFork() | |
| 9485 | - | case "tree": | |
| 9486 | - | i.doSessionTree() | |
| 9487 | - | default: | |
| 9488 | - | i.mu.Lock() | |
| 9489 | - | i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)" | |
| 9490 | - | i.mu.Unlock() | |
| 9491 | - | i.invalidate() | |
| 9492 | - | } | |
| 9493 | - | } | |
| 9494 | 1913 | ||
| 9495 | 1914 | // doSessionExport writes the live session file to destination path | |
| 9496 | 1915 | // dst. When dst is empty we default to ~/Downloads (falling back to | |
| 9497 | 1916 | // the user's home directory if it doesn't exist). The helper | |
| 9498 | 1917 | // expands a leading `~` and creates any missing parent directories. | |
| 9499 | - | func (i *Interactive) doSessionExport(dst string) { | |
| 9500 | - | src := i.currentSessionPath() | |
| 9501 | - | if src == "" { | |
| 9502 | - | i.mu.Lock() | |
| 9503 | - | i.statusErr = "export: no session is active (running with --no-session?)" | |
| 9504 | - | i.mu.Unlock() | |
| 9505 | - | i.invalidate() | |
| 9506 | - | return | |
| 9507 | - | } | |
| 9508 | - | // Persist any in-memory agent messages to the session file so | |
| 9509 | - | // the export carries the full conversation. Without this, the | |
| 9510 | - | // default lazy-flush-at-exit strategy leaves most of a running | |
| 9511 | - | // session unwritten and the export ends up with only the meta. | |
| 9512 | - | if i.cfg.FlushSession != nil { | |
| 9513 | - | i.cfg.FlushSession() | |
| 9514 | - | } | |
| 9515 | - | dst = unquotePath(dst) | |
| 9516 | - | if dst == "" { | |
| 9517 | - | dst = defaultExportDir() | |
| 9518 | - | } else { | |
| 9519 | - | dst = expandTilde(dst) | |
| 9520 | - | } | |
| 9521 | - | out, err := core.ExportSession(src, dst) | |
| 9522 | - | if err != nil { | |
| 9523 | - | i.mu.Lock() | |
| 9524 | - | i.statusErr = "export: " + err.Error() | |
| 9525 | - | i.mu.Unlock() | |
| 9526 | - | i.invalidate() | |
| 9527 | - | return | |
| 9528 | - | } | |
| 9529 | - | i.mu.Lock() | |
| 9530 | - | i.statusOK = "exported session to " + friendlyPath(out) | |
| 9531 | - | i.statusErr = "" | |
| 9532 | - | i.mu.Unlock() | |
| 9533 | - | i.invalidate() | |
| 9534 | - | } | |
| 9535 | 1918 | ||
| 9536 | 1919 | // doSessionImport copies the .zutsession file at src into the | |
| 9537 | 1920 | // running cwd's sessions directory and loads it as the active | |
| 9538 | 1921 | // session, same as `/sessions` -> pick. When src is empty we ask | |
| 9539 | 1922 | // the user to pass a path (no usable default here). | |
| 9540 | - | func (i *Interactive) doSessionImport(src string) { | |
| 9541 | - | src = unquotePath(src) | |
| 9542 | - | if src == "" { | |
| 9543 | - | i.mu.Lock() | |
| 9544 | - | i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zutsession" | |
| 9545 | - | i.mu.Unlock() | |
| 9546 | - | i.invalidate() | |
| 9547 | - | return | |
| 9548 | - | } | |
| 9549 | - | src = expandTilde(src) | |
| 9550 | - | if _, err := os.Stat(src); err != nil { | |
| 9551 | - | i.mu.Lock() | |
| 9552 | - | i.statusErr = "import: " + err.Error() | |
| 9553 | - | i.mu.Unlock() | |
| 9554 | - | i.invalidate() | |
| 9555 | - | return | |
| 9556 | - | } | |
| 9557 | - | newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version) | |
| 9558 | - | if err != nil { | |
| 9559 | - | i.mu.Lock() | |
| 9560 | - | i.statusErr = "import: " + err.Error() | |
| 9561 | - | i.mu.Unlock() | |
| 9562 | - | i.invalidate() | |
| 9563 | - | return | |
| 9564 | - | } | |
| 9565 | - | if i.cfg.LoadSession == nil { | |
| 9566 | - | i.mu.Lock() | |
| 9567 | - | i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)" | |
| 9568 | - | i.statusErr = "" | |
| 9569 | - | i.mu.Unlock() | |
| 9570 | - | i.invalidate() | |
| 9571 | - | return | |
| 9572 | - | } | |
| 9573 | - | i.markSessionTitleSwitching() | |
| 9574 | - | if err := i.cfg.LoadSession(newPath); err != nil { | |
| 9575 | - | i.restoreFailedSessionTitle() | |
| 9576 | - | i.mu.Lock() | |
| 9577 | - | i.statusErr = "import: load failed: " + err.Error() | |
| 9578 | - | i.mu.Unlock() | |
| 9579 | - | i.invalidate() | |
| 9580 | - | return | |
| 9581 | - | } | |
| 9582 | - | i.restoreLoadedSessionTitle() | |
| 9583 | - | state := i.restoreCurrentCompactHandoff() | |
| 9584 | - | i.mu.Lock() | |
| 9585 | - | i.statusOK = "imported and switched to session " + friendlyPath(newPath) | |
| 9586 | - | i.statusErr = "" | |
| 9587 | - | if i.agent != nil { | |
| 9588 | - | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |
| 9589 | - | i.cumUsage = i.agent.Cost() | |
| 9590 | - | last := i.agent.LastTurnUsage() | |
| 9591 | - | i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens | |
| 9592 | - | if len(i.view.Messages) > initialResumeTailLimit { | |
| 9593 | - | i.view.TailLimit = initialResumeTailLimit | |
| 9594 | - | } else { | |
| 9595 | - | i.view.TailLimit = 0 | |
| 9596 | - | } | |
| 9597 | - | i.view.InvalidateRenderCache() | |
| 9598 | - | } | |
| 9599 | - | i.mu.Unlock() | |
| 9600 | - | i.invalidate() | |
| 9601 | - | if state.reason != compactContinuationNone { | |
| 9602 | - | i.startRestoredCompactHandoff(i.runCtx) | |
| 9603 | - | } | |
| 9604 | - | } | |
| 9605 | 1923 | ||
| 9606 | 1924 | // defaultExportDir returns ~/Downloads when it exists, or ~ as a | |
| 9607 | 1925 | // fallback, or /tmp on exotic machines with no home dir. | |
| 9608 | - | func defaultExportDir() string { | |
| 9609 | - | home, err := os.UserHomeDir() | |
| 9610 | - | if err != nil || home == "" { | |
| 9611 | - | return os.TempDir() | |
| 9612 | - | } | |
| 9613 | - | downloads := filepath.Join(home, "Downloads") | |
| 9614 | - | if fi, err := os.Stat(downloads); err == nil && fi.IsDir() { | |
| 9615 | - | return downloads | |
| 9616 | - | } | |
| 9617 | - | return home | |
| 9618 | - | } | |
| 9619 | 1926 | ||
| 9620 | 1927 | // expandTilde turns a leading ~ into the user's home directory. | |
| 9621 | 1928 | // Returns the input unchanged when there's no tilde or no home. | |
| 9622 | - | func expandTilde(p string) string { | |
| 9623 | - | if p == "" || p[0] != '~' { | |
| 9624 | - | return p | |
| 9625 | - | } | |
| 9626 | - | home, err := os.UserHomeDir() | |
| 9627 | - | if err != nil || home == "" { | |
| 9628 | - | return p | |
| 9629 | - | } | |
| 9630 | - | if len(p) == 1 { | |
| 9631 | - | return home | |
| 9632 | - | } | |
| 9633 | - | if p[1] == '/' || p[1] == filepath.Separator { | |
| 9634 | - | return filepath.Join(home, p[2:]) | |
| 9635 | - | } | |
| 9636 | - | return p | |
| 9637 | - | } | |
| 9638 | 1929 | ||
| 9639 | 1930 | // unquotePath strips a matching pair of surrounding single or | |
| 9640 | 1931 | // double quotes. Drag-drop paste in the tui auto-quotes dropped | |
| 9641 | 1932 | // file paths so the shell-like `/session import 'foo bar.zs'` | |
| 9642 | 1933 | // stays well-formed; when the TUI's own slash handler consumes | |
| 9643 | 1934 | // the arg, we want the raw path back. | |
| 9644 | - | func unquotePath(p string) string { | |
| 9645 | - | p = strings.TrimSpace(p) | |
| 9646 | - | if len(p) >= 2 { | |
| 9647 | - | first := p[0] | |
| 9648 | - | last := p[len(p)-1] | |
| 9649 | - | if (first == '\'' && last == '\'') || (first == '"' && last == '"') { | |
| 9650 | - | p = p[1 : len(p)-1] | |
| 9651 | - | } | |
| 9652 | - | } | |
| 9653 | - | return p | |
| 9654 | - | } | |
| 9655 | 1935 | ||
| 9656 | 1936 | // friendlyPath collapses the user's home directory to a leading ~ | |
| 9657 | 1937 | // so status messages read cleanly. Falls back to the raw path when | |
| 9658 | 1938 | // the home dir is unknown. | |
| 9659 | - | func friendlyPath(p string) string { | |
| 9660 | - | home, err := os.UserHomeDir() | |
| 9661 | - | if err != nil || home == "" { | |
| 9662 | - | return p | |
| 9663 | - | } | |
| 9664 | - | if strings.HasPrefix(p, home+string(filepath.Separator)) { | |
| 9665 | - | return "~" + p[len(home):] | |
| 9666 | - | } | |
| 9667 | - | return p | |
| 9668 | - | } | |
| 9669 | 1939 | ||
| 9670 | 1940 | // doSessionFork opens the /jump turn picker in "fork mode". The | |
| 9671 | 1941 | // next selection branches the current session at that user turn | |
| 9672 | 1942 | // instead of scrolling the viewport to it. | |
| 9673 | - | func (i *Interactive) doSessionFork() { | |
| 9674 | - | path := i.currentSessionPath() | |
| 9675 | - | if path == "" { | |
| 9676 | - | i.mu.Lock() | |
| 9677 | - | i.statusErr = "fork: no session is active (running with --no-session?)" | |
| 9678 | - | i.mu.Unlock() | |
| 9679 | - | i.invalidate() | |
| 9680 | - | return | |
| 9681 | - | } | |
| 9682 | - | i.mu.Lock() | |
| 9683 | - | busy := i.busy || i.shellRunning || i.compacting || i.autoCompacting || i.sessionLoading | |
| 9684 | - | queued := len(i.queued) != 0 | |
| 9685 | - | ag := i.agent | |
| 9686 | - | i.mu.Unlock() | |
| 9687 | - | if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 { | |
| 9688 | - | i.mu.Lock() | |
| 9689 | - | i.statusErr = "fork: wait until the current turn and queued messages finish" | |
| 9690 | - | i.mu.Unlock() | |
| 9691 | - | i.invalidate() | |
| 9692 | - | return | |
| 9693 | - | } | |
| 9694 | - | if i.cfg.FlushSession != nil { | |
| 9695 | - | i.cfg.FlushSession() | |
| 9696 | - | } | |
| 9697 | - | snapshot, err := core.ReadSessionSnapshot(path) | |
| 9698 | - | if err != nil { | |
| 9699 | - | i.mu.Lock() | |
| 9700 | - | i.statusErr = "fork: " + err.Error() | |
| 9701 | - | i.mu.Unlock() | |
| 9702 | - | i.invalidate() | |
| 9703 | - | return | |
| 9704 | - | } | |
| 9705 | - | msgs := snapshot.Messages | |
| 9706 | - | if len(msgs) == 0 { | |
| 9707 | - | i.mu.Lock() | |
| 9708 | - | i.statusErr = "fork: transcript is empty; nothing to fork from" | |
| 9709 | - | i.mu.Unlock() | |
| 9710 | - | i.invalidate() | |
| 9711 | - | return | |
| 9712 | - | } | |
| 9713 | - | i.pendingFork = true | |
| 9714 | - | i.jumpDialog.Open(msgs, "") | |
| 9715 | - | i.invalidate() | |
| 9716 | - | } | |
| 9717 | 1943 | ||
| 9718 | 1944 | // doSessionTree opens the current session family after the shared | |
| 9719 | 1945 | // fail-closed gate succeeds. It intentionally does not fall back to the | |
| 9720 | 1946 | // in-memory transcript: a tree that cannot be persisted and reloaded cannot | |
| 9721 | 1947 | // safely create a navigation branch. | |
| 9722 | - | func (i *Interactive) doSessionTree() { | |
| 9723 | - | i.openSessionTree() | |
| 9724 | - | } | |
| 9725 | 1948 | ||
| 9726 | 1949 | // applySessionTreeMessageSelection keeps the old scalar integration | |
| 9727 | 1950 | // source-compatible for package consumers. New dialog selections use the | |
| 9728 | 1951 | // structured sessionTreeTarget path below. | |
| 9729 | - | func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) { | |
| 9730 | - | target := sessionTreeTarget{ | |
| 9731 | - | SourcePath: src, | |
| 9732 | - | EffectiveIndex: msgIdx, | |
| 9733 | - | SelectionBoundary: msgIdx + 1, | |
| 9734 | - | Role: role, | |
| 9735 | - | UserDraft: prompt, | |
| 9736 | - | Boundary: sessionTreeMessageBoundary, | |
| 9737 | - | } | |
| 9738 | - | if role == provider.RoleUser { | |
| 9739 | - | target.SelectionBoundary = msgIdx | |
| 9740 | - | } | |
| 9741 | - | i.applySessionTreeTarget(target, turnNo) | |
| 9742 | - | } | |
| 9743 | 1952 | ||
| 9744 | 1953 | // applySessionTreeTarget checks out a structured dialog target. The target's | |
| 9745 | 1954 | // source and effective indices come from the same preflight snapshot the | |
| 9746 | 1955 | // dialog rendered, while the live read below preserves compatibility with | |
| 9747 | 1956 | // the existing LoadSession(path) error callback. | |
| 9748 | - | func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo int) { | |
| 9749 | - | // Selection is a state-changing operation too. Use the selection gate so | |
| 9750 | - | // a queued/busy turn cannot race the hidden branch or accidentally submit | |
| 9751 | - | // a provider turn while the session is being swapped. The open gate cannot | |
| 9752 | - | // be reused here because the tree dialog itself owns the keyboard. | |
| 9753 | - | if !i.canCommitSessionTreeSelection() { | |
| 9754 | - | i.setSessionTreeError("tree: session branching is not available in this build") | |
| 9755 | - | return | |
| 9756 | - | } | |
| 9757 | - | src := target.SourcePath | |
| 9758 | - | if src == "" { | |
| 9759 | - | src = i.currentSessionPath() | |
| 9760 | - | } | |
| 9761 | - | if src == "" { | |
| 9762 | - | i.setSessionTreeError("tree: no session is active") | |
| 9763 | - | return | |
| 9764 | - | } | |
| 9765 | - | ||
| 9766 | - | // Read and validate the selected row before flushing or creating anything. | |
| 9767 | - | // This gives tool-call/result pairs an atomic boundary and prevents a | |
| 9768 | - | // malformed selection from changing the active session. Historical rows | |
| 9769 | - | // come from a pre-compaction segment; current rows use the effective | |
| 9770 | - | // snapshot that the running agent resumes from. | |
| 9771 | - | var msgs []provider.Message | |
| 9772 | - | var historicalSegment core.SessionHistorySegment | |
| 9773 | - | if target.Historical { | |
| 9774 | - | history, err := core.ReadSessionHistory(src) | |
| 9775 | - | if err != nil { | |
| 9776 | - | i.setSessionTreeError("tree: read selection: " + err.Error()) | |
| 9777 | - | return | |
| 9778 | - | } | |
| 9779 | - | if target.HistorySegment < 0 || target.HistorySegment >= len(history.Segments) { | |
| 9780 | - | i.setSessionTreeError("tree: selected history segment is unavailable") | |
| 9781 | - | return | |
| 9782 | - | } | |
| 9783 | - | historicalSegment = history.Segments[target.HistorySegment] | |
| 9784 | - | msgs = historicalSegment.Messages | |
| 9785 | - | } else { | |
| 9786 | - | sess, current, err := core.OpenSession(src) | |
| 9787 | - | if err != nil { | |
| 9788 | - | i.setSessionTreeError("tree: read selection: " + err.Error()) | |
| 9789 | - | return | |
| 9790 | - | } | |
| 9791 | - | if err := sess.Close(); err != nil { | |
| 9792 | - | i.setSessionTreeError("tree: close selection: " + err.Error()) | |
| 9793 | - | return | |
| 9794 | - | } | |
| 9795 | - | msgs = current | |
| 9796 | - | } | |
| 9797 | - | selection, err := sessionTreeSelection(msgs, target) | |
| 9798 | - | if err != nil { | |
| 9799 | - | i.setSessionTreeError("tree: " + err.Error()) | |
| 9800 | - | return | |
| 9801 | - | } | |
| 9802 | - | if i.cfg.FlushSession != nil { | |
| 9803 | - | i.cfg.FlushSession() | |
| 9804 | - | } | |
| 9805 | - | var newPath string | |
| 9806 | - | if target.Historical { | |
| 9807 | - | newPath, err = core.BranchSessionHiddenFromHistory(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, historicalSegment, selection.upTo) | |
| 9808 | - | } else { | |
| 9809 | - | newPath, err = core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo) | |
| 9810 | - | } | |
| 9811 | - | if err != nil { | |
| 9812 | - | i.setSessionTreeError("tree: " + err.Error()) | |
| 9813 | - | return | |
| 9814 | - | } | |
| 9815 | - | i.markSessionTitleSwitching() | |
| 9816 | - | if err := i.cfg.LoadSession(newPath); err != nil { | |
| 9817 | - | i.restoreFailedSessionTitle() | |
| 9818 | - | i.setSessionTreeError("tree: checkout failed: " + err.Error()) | |
| 9819 | - | return | |
| 9820 | - | } | |
| 9821 | - | state := i.restoreCurrentCompactHandoff() | |
| 9822 | - | ||
| 9823 | - | // LoadSession is intentionally the old func(string) error callback: the | |
| 9824 | - | // CLI and embedders already own agent/session swapping. Refresh the view | |
| 9825 | - | // from the swapped agent here instead of requiring a new callback result. | |
| 9826 | - | i.resetSessionTitleForFreshBranch() | |
| 9827 | - | i.scrollToBottom() | |
| 9828 | - | i.mu.Lock() | |
| 9829 | - | i.lastCtxInput = 0 | |
| 9830 | - | if i.agent != nil { | |
| 9831 | - | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |
| 9832 | - | i.cumUsage = i.agent.Cost() | |
| 9833 | - | last := i.agent.LastTurnUsage() | |
| 9834 | - | i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens | |
| 9835 | - | i.view.InvalidateRenderCache() | |
| 9836 | - | } | |
| 9837 | - | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 9838 | - | i.toolOrder = nil | |
| 9839 | - | i.toolGate = map[string]int{} | |
| 9840 | - | i.extNotes = nil | |
| 9841 | - | if selection.restoreDraft { | |
| 9842 | - | i.ed.SetValue(selection.draftText) | |
| 9843 | - | i.clipboardImages = selection.images | |
| 9844 | - | i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo) | |
| 9845 | - | } else { | |
| 9846 | - | i.ed.Clear() | |
| 9847 | - | i.clipboardImages = nil | |
| 9848 | - | i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo) | |
| 9849 | - | } | |
| 9850 | - | i.statusErr = "" | |
| 9851 | - | i.mu.Unlock() | |
| 9852 | - | i.scrollToBottom() | |
| 9853 | - | // Selection does not submit, start, or queue the restored user draft; only | |
| 9854 | - | // a later explicit Enter does that. An inherited compact handoff may resume | |
| 9855 | - | // its hidden continuation below when this branch has the complete effective | |
| 9856 | - | // transcript, without submitting the restored draft. | |
| 9857 | - | i.invalidate() | |
| 9858 | - | if state.reason != compactContinuationNone { | |
| 9859 | - | i.startRestoredCompactHandoff(i.runCtx) | |
| 9860 | - | } | |
| 9861 | - | } | |
| 9862 | 1957 | ||
| 9863 | 1958 | // applyForkSelection branches the current session at msgIdx+1 (so | |
| 9864 | 1959 | // the selected user message and everything before it is included | |
| 9865 | 1960 | // in the new branch), then switches the running agent to the new | |
| 9866 | 1961 | // file. Called from the jump-dialog handler when pendingFork=true. | |
| 9867 | - | func (i *Interactive) applyForkSelection(msgIdx int) { | |
| 9868 | - | i.pendingFork = false | |
| 9869 | - | src := i.currentSessionPath() | |
| 9870 | - | if src == "" { | |
| 9871 | - | i.mu.Lock() | |
| 9872 | - | i.statusErr = "fork: no session is active" | |
| 9873 | - | i.mu.Unlock() | |
| 9874 | - | i.invalidate() | |
| 9875 | - | return | |
| 9876 | - | } | |
| 9877 | - | if i.cfg.FlushSession != nil { | |
| 9878 | - | i.cfg.FlushSession() | |
| 9879 | - | } | |
| 9880 | - | // msgIdx is 0-indexed message position; copy msgIdx+1 rows so | |
| 9881 | - | // the selected user message is included. | |
| 9882 | - | upTo := msgIdx + 1 | |
| 9883 | - | newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo) | |
| 9884 | - | if err != nil { | |
| 9885 | - | i.mu.Lock() | |
| 9886 | - | i.statusErr = "fork: " + err.Error() | |
| 9887 | - | i.mu.Unlock() | |
| 9888 | - | i.invalidate() | |
| 9889 | - | return | |
| 9890 | - | } | |
| 9891 | - | if i.cfg.LoadSession == nil { | |
| 9892 | - | i.mu.Lock() | |
| 9893 | - | i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)" | |
| 9894 | - | i.statusErr = "" | |
| 9895 | - | i.mu.Unlock() | |
| 9896 | - | i.invalidate() | |
| 9897 | - | return | |
| 9898 | - | } | |
| 9899 | - | i.markSessionTitleSwitching() | |
| 9900 | - | if err := i.cfg.LoadSession(newPath); err != nil { | |
| 9901 | - | i.restoreFailedSessionTitle() | |
| 9902 | - | i.mu.Lock() | |
| 9903 | - | i.statusErr = "fork: switch failed: " + err.Error() | |
| 9904 | - | i.mu.Unlock() | |
| 9905 | - | i.invalidate() | |
| 9906 | - | return | |
| 9907 | - | } | |
| 9908 | - | state := i.restoreCurrentCompactHandoff() | |
| 9909 | - | i.resetSessionTitleForFreshBranch() | |
| 9910 | - | i.mu.Lock() | |
| 9911 | - | i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath) | |
| 9912 | - | i.statusErr = "" | |
| 9913 | - | i.mu.Unlock() | |
| 9914 | - | i.invalidate() | |
| 9915 | - | if state.reason != compactContinuationNone { | |
| 9916 | - | i.startRestoredCompactHandoff(i.runCtx) | |
| 9917 | - | } | |
| 9918 | - | } | |
| 9919 | 1962 | ||
| 9920 | 1963 | // formatInt is a tiny strconv.Itoa shim; keeps the handler above | |
| 9921 | 1964 | // from needing a strconv import just for one call. | |
| 9922 | - | func formatInt(n int) string { | |
| 9923 | - | return fmt.Sprintf("%d", n) | |
| 9924 | - | } | |
| 9925 | 1965 | ||
| 9926 | 1966 | // assistantText returns the concatenated text of every TextBlock in | |
| 9927 | 1967 | // m. Used by the streaming-view dedupe guard to tell when a live | |
| 9928 | 1968 | // streamed reply has already been promoted into the transcript. | |
| 9929 | - | func assistantText(m provider.Message) string { | |
| 9930 | - | var sb strings.Builder | |
| 9931 | - | for _, c := range m.Content { | |
| 9932 | - | if tb, ok := c.(provider.TextBlock); ok { | |
| 9933 | - | if sb.Len() > 0 { | |
| 9934 | - | sb.WriteByte('\n') | |
| 9935 | - | } | |
| 9936 | - | sb.WriteString(tb.Text) | |
| 9937 | - | } | |
| 9938 | - | } | |
| 9939 | - | return sb.String() | |
| 9940 | - | } | |
| 9941 | 1969 | ||
| 9942 | 1970 | // resetTranscriptRenderLocked invalidates every render cache that assumes | |
| 9943 | 1971 | // the previous transcript remains structurally intact. Compaction is a | |
| 9944 | 1972 | // replacement, not an append: the flow renderer must repaint from the new | |
| 9945 | 1973 | // transcript rather than diff it against scrollback rows from the old one. | |
| 9946 | 1974 | // Must be called with i.mu held. | |
| 9947 | - | func (i *Interactive) resetTranscriptRenderLocked() { | |
| 9948 | - | i.view.InvalidateRenderCache() | |
| 9949 | - | i.chatCacheValid = false | |
| 9950 | - | i.prevChatLen = 0 | |
| 9951 | - | i.prevChatCols = 0 | |
| 9952 | - | i.prevChatRows = 0 | |
| 9953 | - | i.prevScrollOffset = 0 | |
| 9954 | - | i.requestRendererInvalidate() | |
| 9955 | - | } | |
| 9956 | 1975 | ||
| 9957 | 1976 | // resetStreamingStateLocked clears every piece of streaming state | |
| 9958 | 1977 | // in one shot. Used by abort paths (turn cancel, compact hand-off, | |
| 9959 | 1978 | // queue drain) so the pacer doesn't keep draining stale runes from | |
| 9960 | 1979 | // a prior turn. Must be called with i.mu held. | |
| 9961 | - | func (i *Interactive) resetStreamingStateLocked() { | |
| 9962 | - | i.streaming.Reset() | |
| 9963 | - | i.streamPending = i.streamPending[:0] | |
| 9964 | - | i.streamFlushPending = false | |
| 9965 | - | i.streamOn = false | |
| 9966 | - | i.openAllToolGatesLocked() | |
| 9967 | - | } | |
| 9968 | 1980 | ||
| 9969 | 1981 | // openAllToolGatesLocked drops every pending tool gate so that any | |
| 9970 | 1982 | // tool registered during this turn renders unconditionally from now | |
| @@ -9973,11 +1985,6 @@ func (i *Interactive) resetStreamingStateLocked() { | |||
| 9973 | 1985 | // this, the gate comparison against a freshly-reset streaming buffer | |
| 9974 | 1986 | // would wrongly re-hide tools that had already cleared their gate. | |
| 9975 | 1987 | // Must be called with i.mu held. | |
| 9976 | - | func (i *Interactive) openAllToolGatesLocked() { | |
| 9977 | - | for id := range i.toolGate { | |
| 9978 | - | i.toolGate[id] = 0 | |
| 9979 | - | } | |
| 9980 | - | } | |
| 9981 | 1988 | ||
| 9982 | 1989 | // gateToolLocked records the stream position at which a tool call may | |
| 9983 | 1990 | // become visible. The gate is the total length the streaming buffer | |
| @@ -9992,27 +1999,10 @@ func (i *Interactive) openAllToolGatesLocked() { | |||
| 9992 | 1999 | // behaviour for tool-only turns and replayed sessions. First | |
| 9993 | 2000 | // registration wins so a later EvToolCall can't move an existing | |
| 9994 | 2001 | // gate. Must be called with i.mu held. | |
| 9995 | - | func (i *Interactive) gateToolLocked(id string) { | |
| 9996 | - | if _, ok := i.toolGate[id]; ok { | |
| 9997 | - | return | |
| 9998 | - | } | |
| 9999 | - | if !i.streamOn { | |
| 10000 | - | i.toolGate[id] = 0 | |
| 10001 | - | return | |
| 10002 | - | } | |
| 10003 | - | i.toolGate[id] = i.streaming.Len() + len(i.streamPending) | |
| 10004 | - | } | |
| 10005 | 2002 | ||
| 10006 | 2003 | // toolGateOpenLocked reports whether the gated tool block may render | |
| 10007 | 2004 | // yet, i.e. the pacer has drained enough text to reach the position | |
| 10008 | 2005 | // recorded when the tool call arrived. Must be called with i.mu held. | |
| 10009 | - | func (i *Interactive) toolGateOpenLocked(id string) bool { | |
| 10010 | - | gate, ok := i.toolGate[id] | |
| 10011 | - | if !ok || gate == 0 { | |
| 10012 | - | return true | |
| 10013 | - | } | |
| 10014 | - | return i.streaming.Len() >= gate | |
| 10015 | - | } | |
| 10016 | 2006 | ||
| 10017 | 2007 | // assistantMessageSideEffects runs the non-visual hooks attached to | |
| 10018 | 2008 | // EvAssistantMessage: the host-provided OnAssistant callback and the | |
| @@ -10023,25 +2013,6 @@ func (i *Interactive) toolGateOpenLocked(id string) bool { | |||
| 10023 | 2013 | // the callbacks themselves must fire on message arrival so | |
| 10024 | 2014 | // downstream observers (session persistence, telegram, cost panels) | |
| 10025 | 2015 | // don't wait on a UI animation to catch up. | |
| 10026 | - | func (i *Interactive) assistantMessageSideEffects(m provider.Message) { | |
| 10027 | - | if i.cfg.OnAssistant != nil { | |
| 10028 | - | i.cfg.OnAssistant(m) | |
| 10029 | - | } | |
| 10030 | - | if i.telegramBridge != nil && i.telegramBridge.Active() { | |
| 10031 | - | var sb strings.Builder | |
| 10032 | - | for _, c := range m.Content { | |
| 10033 | - | if tb, ok := c.(provider.TextBlock); ok { | |
| 10034 | - | if sb.Len() > 0 { | |
| 10035 | - | sb.WriteString("\n") | |
| 10036 | - | } | |
| 10037 | - | sb.WriteString(tb.Text) | |
| 10038 | - | } | |
| 10039 | - | } | |
| 10040 | - | if text := sb.String(); strings.TrimSpace(text) != "" { | |
| 10041 | - | go i.telegramBridge.OnAssistantText(text) | |
| 10042 | - | } | |
| 10043 | - | } | |
| 10044 | - | } | |
| 10045 | 2016 | ||
| 10046 | 2017 | // paintPaceRate is how many runes the streaming pacer releases per | |
| 10047 | 2018 | // tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough | |
| @@ -10065,41 +2036,3 @@ const paintPaceInterval = 16 * time.Millisecond | |||
| 10065 | 2036 | // summary into 3 fat chunks, visually indistinguishable from "the | |
| 10066 | 2037 | // whole reply just appeared". The pacer normalizes that so every | |
| 10067 | 2038 | // path looks the same on screen. | |
| 10068 | - | func (i *Interactive) runStreamPacer(ctx context.Context) { | |
| 10069 | - | t := time.NewTicker(paintPaceInterval) | |
| 10070 | - | defer t.Stop() | |
| 10071 | - | for { | |
| 10072 | - | select { | |
| 10073 | - | case <-ctx.Done(): | |
| 10074 | - | return | |
| 10075 | - | case <-t.C: | |
| 10076 | - | i.mu.Lock() | |
| 10077 | - | if len(i.streamPending) == 0 { | |
| 10078 | - | // EvAssistantMessage already fired but the pacer | |
| 10079 | - | // was still draining a tick ago. Everything is now | |
| 10080 | - | // painted; clear the streaming flags so the next | |
| 10081 | - | // redraw shows the finalised transcript message | |
| 10082 | - | // and hides the streaming overlay. | |
| 10083 | - | if i.streamFlushPending { | |
| 10084 | - | i.streamFlushPending = false | |
| 10085 | - | i.streaming.Reset() | |
| 10086 | - | i.streamOn = false | |
| 10087 | - | i.openAllToolGatesLocked() | |
| 10088 | - | i.mu.Unlock() | |
| 10089 | - | i.invalidate() | |
| 10090 | - | continue | |
| 10091 | - | } | |
| 10092 | - | i.mu.Unlock() | |
| 10093 | - | continue | |
| 10094 | - | } | |
| 10095 | - | n := paintPaceRate | |
| 10096 | - | if n > len(i.streamPending) { | |
| 10097 | - | n = len(i.streamPending) | |
| 10098 | - | } | |
| 10099 | - | i.streaming.WriteString(string(i.streamPending[:n])) | |
| 10100 | - | i.streamPending = i.streamPending[n:] | |
| 10101 | - | i.mu.Unlock() | |
| 10102 | - | i.invalidate() | |
| 10103 | - | } | |
| 10104 | - | } | |
| 10105 | - | } | |
A packages/agent/modes/interactive_alerts.go +79 -0
| @@ -0,0 +1,79 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zut/packages/agent/extproto" | |
| 7 | + | "github.com/bnema/zut/packages/tui" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Notify is the manager's NotifyFromExt entry point. | |
| 11 | + | func (i *Interactive) Notify(extName, level, message string) { | |
| 12 | + | i.appendExtensionNote(extName, message, level) | |
| 13 | + | i.invalidate() | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // Alert is the manager's AlertFromExt entry point. Alerts are emitted | |
| 17 | + | // through the same terminal writer as redraws while holding the TUI mutex, | |
| 18 | + | // so a BEL cannot be interleaved with a frame update. | |
| 19 | + | func (i *Interactive) Alert(_ string, alert extproto.AlertRequest) { | |
| 20 | + | i.mu.Lock() | |
| 21 | + | i.emitAlertLocked(alert) | |
| 22 | + | i.mu.Unlock() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func terminalAlertsEnabled(enabled *bool) bool { | |
| 26 | + | return enabled == nil || *enabled | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // emitAlertLocked applies the shared terminal-alert policy. The caller must | |
| 30 | + | // hold i.mu because terminal writes share the renderer's output boundary. | |
| 31 | + | func (i *Interactive) emitAlertLocked(alert extproto.AlertRequest) { | |
| 32 | + | if alert.Kind != extproto.AlertKindBell || !terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) || i.cfg.Terminal == nil { | |
| 33 | + | return | |
| 34 | + | } | |
| 35 | + | _ = tui.WriteBell(i.cfg.Terminal) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // scheduleMainAlert defers a main-session alert until the next redraw | |
| 39 | + | // commits the final frame. Keeping this deferred even when no paced text is | |
| 40 | + | // pending avoids racing the pacer's final state transition. | |
| 41 | + | func (i *Interactive) scheduleMainAlert(reason string) { | |
| 42 | + | if reason == "" { | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | i.mu.Lock() | |
| 46 | + | i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason} | |
| 47 | + | i.mu.Unlock() | |
| 48 | + | i.invalidate() | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // ClearNotes removes every note line owned by extName from the | |
| 52 | + | // bottom-sticky ext-notes block. Extensions use this to retract a | |
| 53 | + | // transient status line (e.g. an approval prompt) once it no longer | |
| 54 | + | // applies, instead of leaving it stacked forever. Notes from other | |
| 55 | + | // extensions and internal notes (auto-compact) are left untouched. | |
| 56 | + | func (i *Interactive) ClearNotes(extName string) { | |
| 57 | + | marker := "[" + extName + "] " | |
| 58 | + | i.mu.Lock() | |
| 59 | + | if len(i.extNotes) == 0 { | |
| 60 | + | i.mu.Unlock() | |
| 61 | + | return | |
| 62 | + | } | |
| 63 | + | kept := i.extNotes[:0:0] | |
| 64 | + | changed := false | |
| 65 | + | for _, line := range i.extNotes { | |
| 66 | + | if strings.Contains(line, marker) { | |
| 67 | + | changed = true | |
| 68 | + | continue | |
| 69 | + | } | |
| 70 | + | kept = append(kept, line) | |
| 71 | + | } | |
| 72 | + | if changed { | |
| 73 | + | i.extNotes = kept | |
| 74 | + | } | |
| 75 | + | i.mu.Unlock() | |
| 76 | + | if changed { | |
| 77 | + | i.invalidate() | |
| 78 | + | } | |
| 79 | + | } | |
A packages/agent/modes/interactive_commands.go +1226 -0
| @@ -0,0 +1,1226 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/zut/packages/agent/skills" | |
| 13 | + | "github.com/bnema/zut/packages/agent/tools" | |
| 14 | + | "github.com/bnema/zut/packages/core" | |
| 15 | + | "github.com/bnema/zut/packages/provider" | |
| 16 | + | "github.com/bnema/zut/packages/provider/auth" | |
| 17 | + | "github.com/bnema/zut/packages/tui" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func buildStudyPrompt(arg, cwd string) string { | |
| 21 | + | arg = strings.TrimSpace(arg) | |
| 22 | + | if arg == "" { | |
| 23 | + | return "Read and understand everything in the current directory." | |
| 24 | + | } | |
| 25 | + | abs := arg | |
| 26 | + | if !filepath.IsAbs(abs) { | |
| 27 | + | abs = filepath.Join(cwd, abs) | |
| 28 | + | } | |
| 29 | + | display := arg | |
| 30 | + | if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") { | |
| 31 | + | display = rel | |
| 32 | + | } | |
| 33 | + | if info, err := os.Stat(abs); err == nil && !info.IsDir() { | |
| 34 | + | return "Read and understand the file " + display + "." | |
| 35 | + | } | |
| 36 | + | return "Read and understand everything in the directory " + display + "." | |
| 37 | + | } | |
| 38 | + | func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool { | |
| 39 | + | if ed == nil { | |
| 40 | + | return false | |
| 41 | + | } | |
| 42 | + | val := ed.Value() | |
| 43 | + | // Find the trailing run of non-whitespace. | |
| 44 | + | start := len(val) | |
| 45 | + | for start > 0 { | |
| 46 | + | r := val[start-1] | |
| 47 | + | if r == ' ' || r == '\t' || r == '\n' { | |
| 48 | + | break | |
| 49 | + | } | |
| 50 | + | start-- | |
| 51 | + | } | |
| 52 | + | token := val[start:] | |
| 53 | + | if token == "" { | |
| 54 | + | return false | |
| 55 | + | } | |
| 56 | + | if !looksLikePathToken(token) { | |
| 57 | + | return false | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Resolve the absolute parent directory + base prefix to match. | |
| 61 | + | parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd) | |
| 62 | + | if !ok { | |
| 63 | + | return true | |
| 64 | + | } | |
| 65 | + | entries, err := os.ReadDir(parentAbs) | |
| 66 | + | if err != nil { | |
| 67 | + | return true | |
| 68 | + | } | |
| 69 | + | var names []string | |
| 70 | + | var isDir []bool | |
| 71 | + | for _, e := range entries { | |
| 72 | + | name := e.Name() | |
| 73 | + | if !strings.HasPrefix(name, basePrefix) { | |
| 74 | + | continue | |
| 75 | + | } | |
| 76 | + | // Hide dotfiles unless the user explicitly typed a leading dot, | |
| 77 | + | // mirroring bash's default behaviour. | |
| 78 | + | if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") { | |
| 79 | + | continue | |
| 80 | + | } | |
| 81 | + | names = append(names, name) | |
| 82 | + | isDir = append(isDir, e.IsDir()) | |
| 83 | + | } | |
| 84 | + | if len(names) == 0 { | |
| 85 | + | return true | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | var completed string | |
| 89 | + | var completedIsDir bool | |
| 90 | + | if len(names) == 1 { | |
| 91 | + | completed = names[0] | |
| 92 | + | completedIsDir = isDir[0] | |
| 93 | + | } else { | |
| 94 | + | completed = longestCommonPrefix(names) | |
| 95 | + | if completed == basePrefix { | |
| 96 | + | // Already at the deepest unambiguous prefix; nothing to add. | |
| 97 | + | return true | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Build the replacement token in the same display form the user | |
| 102 | + | // typed (preserve ~ vs absolute vs relative). | |
| 103 | + | newToken := displayParent + completed | |
| 104 | + | if len(names) == 1 && completedIsDir { | |
| 105 | + | newToken += "/" | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | ed.SetValue(val[:start] + newToken) | |
| 109 | + | return true | |
| 110 | + | } | |
| 111 | + | func (i *Interactive) tryPathTabComplete() bool { | |
| 112 | + | if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) { | |
| 113 | + | i.invalidate() | |
| 114 | + | return true | |
| 115 | + | } | |
| 116 | + | return false | |
| 117 | + | } | |
| 118 | + | func looksLikePathToken(tok string) bool { | |
| 119 | + | if tok == "" { | |
| 120 | + | return false | |
| 121 | + | } | |
| 122 | + | if tok[0] == '~' || tok[0] == '/' { | |
| 123 | + | return true | |
| 124 | + | } | |
| 125 | + | if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") { | |
| 126 | + | return true | |
| 127 | + | } | |
| 128 | + | return strings.Contains(tok, "/") | |
| 129 | + | } | |
| 130 | + | func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) { | |
| 131 | + | // Detect ~ expansion. | |
| 132 | + | expanded := tok | |
| 133 | + | homePrefix := "" | |
| 134 | + | if tok == "~" { | |
| 135 | + | home, err := os.UserHomeDir() | |
| 136 | + | if err != nil || home == "" { | |
| 137 | + | return "", "", "", false | |
| 138 | + | } | |
| 139 | + | // "~" alone: complete in $HOME. parent = home, base = "". | |
| 140 | + | return home, "", "~/", true | |
| 141 | + | } | |
| 142 | + | if strings.HasPrefix(tok, "~/") { | |
| 143 | + | home, err := os.UserHomeDir() | |
| 144 | + | if err != nil || home == "" { | |
| 145 | + | return "", "", "", false | |
| 146 | + | } | |
| 147 | + | expanded = home + tok[1:] | |
| 148 | + | homePrefix = "~" | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | dir, base := splitDirBase(expanded) | |
| 152 | + | if !filepath.IsAbs(dir) { | |
| 153 | + | dir = filepath.Join(cwd, dir) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // Reconstruct the display form the user typed for the parent, | |
| 157 | + | // keeping ~ when they used it. The base is dropped — the caller | |
| 158 | + | // substitutes the completed name. | |
| 159 | + | display := tok[:len(tok)-len(base)] | |
| 160 | + | if homePrefix != "" && !strings.HasPrefix(display, "~") { | |
| 161 | + | display = homePrefix + display[len(homePrefix):] | |
| 162 | + | } | |
| 163 | + | return dir, base, display, true | |
| 164 | + | } | |
| 165 | + | func splitDirBase(p string) (dir, base string) { | |
| 166 | + | if p == "" { | |
| 167 | + | return ".", "" | |
| 168 | + | } | |
| 169 | + | i := strings.LastIndex(p, "/") | |
| 170 | + | if i < 0 { | |
| 171 | + | return ".", p | |
| 172 | + | } | |
| 173 | + | return p[:i+1], p[i+1:] | |
| 174 | + | } | |
| 175 | + | func longestCommonPrefix(ss []string) string { | |
| 176 | + | if len(ss) == 0 { | |
| 177 | + | return "" | |
| 178 | + | } | |
| 179 | + | prefix := ss[0] | |
| 180 | + | for _, s := range ss[1:] { | |
| 181 | + | n := 0 | |
| 182 | + | for n < len(prefix) && n < len(s) && prefix[n] == s[n] { | |
| 183 | + | n++ | |
| 184 | + | } | |
| 185 | + | prefix = prefix[:n] | |
| 186 | + | if prefix == "" { | |
| 187 | + | return "" | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | return prefix | |
| 191 | + | } | |
| 192 | + | func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |
| 193 | + | parts := strings.Fields(cmd) | |
| 194 | + | if len(parts) == 0 { | |
| 195 | + | return false | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | if isSkillCommand(cmd) { | |
| 199 | + | var available []*skills.Skill | |
| 200 | + | if i.cfg.SkillSnapshot != nil { | |
| 201 | + | available = i.cfg.SkillSnapshot() | |
| 202 | + | } | |
| 203 | + | prompt, _, err := expandSkillCommand(cmd, available) | |
| 204 | + | if err != nil { | |
| 205 | + | i.mu.Lock() | |
| 206 | + | i.statusErr = err.Error() | |
| 207 | + | i.statusOK = "" | |
| 208 | + | i.mu.Unlock() | |
| 209 | + | return false | |
| 210 | + | } | |
| 211 | + | i.submitOrQueuePrompt(ctx, prompt) | |
| 212 | + | return false | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | switch strings.ToLower(parts[0]) { | |
| 216 | + | case "/goal": | |
| 217 | + | i.runGoalCommand(ctx, cmd, parts) | |
| 218 | + | case "/exit": | |
| 219 | + | return true | |
| 220 | + | case "/clear": | |
| 221 | + | if i.agent != nil { | |
| 222 | + | i.agent.SetMessages(nil) | |
| 223 | + | } | |
| 224 | + | i.mu.Lock() | |
| 225 | + | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 226 | + | i.toolOrder = nil | |
| 227 | + | i.toolGate = map[string]int{} | |
| 228 | + | i.statusErr = "" | |
| 229 | + | i.statusOK = "" | |
| 230 | + | i.helpBlock = nil | |
| 231 | + | i.parkedTurn = 0 | |
| 232 | + | i.parkedTotal = 0 | |
| 233 | + | i.scrollOffset = 0 | |
| 234 | + | i.extNotes = nil | |
| 235 | + | i.reloadErrors = nil | |
| 236 | + | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 237 | + | i.view.InvalidateRenderCache() | |
| 238 | + | i.mu.Unlock() | |
| 239 | + | if persistHandoff { | |
| 240 | + | i.persistCompactHandoff(handoff) | |
| 241 | + | } | |
| 242 | + | case "/help": | |
| 243 | + | i.mu.Lock() | |
| 244 | + | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured) | |
| 245 | + | i.statusErr = "" | |
| 246 | + | i.statusOK = "" | |
| 247 | + | // Pin the viewport to the newest content so the help block, | |
| 248 | + | // which we just appended to the end of the transcript, is | |
| 249 | + | // what the user actually sees. | |
| 250 | + | i.scrollOffset = 0 | |
| 251 | + | i.mu.Unlock() | |
| 252 | + | case "/login": | |
| 253 | + | i.dialog.Open(i.cfg.ZutHome) | |
| 254 | + | case "/logout": | |
| 255 | + | if len(parts) >= 2 { | |
| 256 | + | // Explicit target: /logout anthropic | openai | all | |
| 257 | + | i.doLogout(parts[1]) | |
| 258 | + | break | |
| 259 | + | } | |
| 260 | + | // No arg: open the picker over whichever providers are | |
| 261 | + | // currently logged in. If nothing's logged in, bail with a | |
| 262 | + | // status line. | |
| 263 | + | i.openLogoutDialog() | |
| 264 | + | case "/model": | |
| 265 | + | if len(parts) >= 2 { | |
| 266 | + | i.applyModelSelection("", parts[1]) | |
| 267 | + | } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil { | |
| 268 | + | i.mu.Lock() | |
| 269 | + | i.modelRefreshing = true | |
| 270 | + | i.statusOK = "Refreshing models" | |
| 271 | + | i.statusErr = "" | |
| 272 | + | i.mu.Unlock() | |
| 273 | + | go func() { | |
| 274 | + | refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 275 | + | defer cancel() | |
| 276 | + | i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)} | |
| 277 | + | }() | |
| 278 | + | } else { | |
| 279 | + | i.openModelPickerAfterRefresh(nil) | |
| 280 | + | } | |
| 281 | + | case "/llama": | |
| 282 | + | if i.cfg.LlamaCPPConfig == nil { | |
| 283 | + | i.mu.Lock() | |
| 284 | + | i.statusErr = "llama.cpp configuration is unavailable" | |
| 285 | + | i.statusOK = "" | |
| 286 | + | i.mu.Unlock() | |
| 287 | + | break | |
| 288 | + | } | |
| 289 | + | baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig() | |
| 290 | + | if configErr == nil { | |
| 291 | + | configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate) | |
| 292 | + | } | |
| 293 | + | if configErr != nil { | |
| 294 | + | i.mu.Lock() | |
| 295 | + | i.statusErr = configErr.Error() | |
| 296 | + | i.statusOK = "" | |
| 297 | + | i.mu.Unlock() | |
| 298 | + | } | |
| 299 | + | case "/reasoning": | |
| 300 | + | i.openReasoningDialog() | |
| 301 | + | case "/fast": | |
| 302 | + | enabled := i.cfg.FastMode == nil || !*i.cfg.FastMode | |
| 303 | + | i.applySettingToggle("fast_mode", enabled) | |
| 304 | + | case "/orchestrator": | |
| 305 | + | enabled := i.cfg.AutoSubagentsEnabled == nil || !*i.cfg.AutoSubagentsEnabled | |
| 306 | + | i.applySettingToggle("auto_subagents_enabled", enabled) | |
| 307 | + | case "/settings": | |
| 308 | + | i.openSettingsDialog() | |
| 309 | + | case "/sessions": | |
| 310 | + | i.mu.Lock() | |
| 311 | + | i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD) | |
| 312 | + | i.mu.Unlock() | |
| 313 | + | case "/fork": | |
| 314 | + | i.doSessionFork() | |
| 315 | + | case "/jump": | |
| 316 | + | i.openJumpDialog(parts[1:]) | |
| 317 | + | case "/btw": | |
| 318 | + | i.openBtwDialog(parts[1:]) | |
| 319 | + | case "/skills": | |
| 320 | + | i.openSkillsDialog() | |
| 321 | + | case "/compact": | |
| 322 | + | i.runCompact(ctx, compactContinuationRequest{origin: compactOriginManual}) | |
| 323 | + | case "/study": | |
| 324 | + | // Canned prompt that tells the agent to read every file | |
| 325 | + | // in some target so its later turns have the whole thing | |
| 326 | + | // in context. With no argument, the target is the current | |
| 327 | + | // directory. With an argument, the target is whatever the | |
| 328 | + | // user passed — typed by hand, drag-dropped, or selected | |
| 329 | + | // via the @ file picker (which is why we accept both files | |
| 330 | + | // and directories; the @-picker chips for both have already | |
| 331 | + | // been expanded to absolute paths by expandFileChips above). | |
| 332 | + | // Dispatched through the normal queue-or-start path so it | |
| 333 | + | // behaves identically to typing the prompt by hand. | |
| 334 | + | studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD) | |
| 335 | + | i.mu.Lock() | |
| 336 | + | busy := i.busy | |
| 337 | + | compacting := i.compacting | |
| 338 | + | ag := i.agent | |
| 339 | + | i.mu.Unlock() | |
| 340 | + | if busy { | |
| 341 | + | var handoff json.RawMessage | |
| 342 | + | var persistHandoff bool | |
| 343 | + | if ag != nil && !compacting { | |
| 344 | + | i.mu.Lock() | |
| 345 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 346 | + | ag.QueueMessage(studyPrompt) | |
| 347 | + | i.mu.Unlock() | |
| 348 | + | } else { | |
| 349 | + | i.mu.Lock() | |
| 350 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 351 | + | i.queued = append(i.queued, studyPrompt) | |
| 352 | + | i.mu.Unlock() | |
| 353 | + | } | |
| 354 | + | if persistHandoff { | |
| 355 | + | i.persistCompactHandoff(handoff) | |
| 356 | + | } | |
| 357 | + | i.invalidate() | |
| 358 | + | break | |
| 359 | + | } | |
| 360 | + | i.startTurn(ctx, studyPrompt) | |
| 361 | + | case "/cd": | |
| 362 | + | // Hidden command: switch the running session's cwd. Not in | |
| 363 | + | // slash_suggest, not in /help. Used by the workspaces | |
| 364 | + | // extension's panel-key Enter handler so picking a row | |
| 365 | + | // jumps zut into that directory without relaunching. | |
| 366 | + | // | |
| 367 | + | // Recovers the raw argument (path) from the original cmd | |
| 368 | + | // string rather than parts, so paths with spaces survive. | |
| 369 | + | // The host's ChangeCWD hook handles validation, session | |
| 370 | + | // close + reopen, agent rebuild, sandbox re-rooting, and | |
| 371 | + | // re-jail-if-jailed semantics. | |
| 372 | + | if i.cfg.ChangeCWD == nil { | |
| 373 | + | i.mu.Lock() | |
| 374 | + | i.statusErr = "/cd unavailable: host did not wire ChangeCWD" | |
| 375 | + | i.mu.Unlock() | |
| 376 | + | break | |
| 377 | + | } | |
| 378 | + | path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])) | |
| 379 | + | if path == "" { | |
| 380 | + | i.mu.Lock() | |
| 381 | + | i.statusErr = "/cd: missing path" | |
| 382 | + | i.mu.Unlock() | |
| 383 | + | break | |
| 384 | + | } | |
| 385 | + | if err := i.cfg.ChangeCWD(path); err != nil { | |
| 386 | + | i.mu.Lock() | |
| 387 | + | i.statusErr = "/cd: " + err.Error() | |
| 388 | + | i.statusOK = "" | |
| 389 | + | i.mu.Unlock() | |
| 390 | + | break | |
| 391 | + | } | |
| 392 | + | // ChangeCWD has already updated i.cfg.CWD and swapped the | |
| 393 | + | // agent + session. Reset transient TUI state so the new | |
| 394 | + | // session opens clean. | |
| 395 | + | i.mu.Lock() | |
| 396 | + | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 397 | + | i.toolOrder = nil | |
| 398 | + | i.toolGate = map[string]int{} | |
| 399 | + | i.helpBlock = nil | |
| 400 | + | i.parkedTurn = 0 | |
| 401 | + | i.statusOK = "cwd " + i.cfg.CWD | |
| 402 | + | i.statusErr = "" | |
| 403 | + | i.mu.Unlock() | |
| 404 | + | i.fileSuggest.Reset() | |
| 405 | + | i.fileSuggest.SetCWD(i.cfg.CWD) | |
| 406 | + | i.invalidate() | |
| 407 | + | case "/jail": | |
| 408 | + | if i.cfg.Sandbox == nil { | |
| 409 | + | i.mu.Lock() | |
| 410 | + | i.statusErr = "sandbox not available in this build" | |
| 411 | + | i.mu.Unlock() | |
| 412 | + | break | |
| 413 | + | } | |
| 414 | + | i.cfg.Sandbox.Lock() | |
| 415 | + | i.mu.Lock() | |
| 416 | + | i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)" | |
| 417 | + | i.statusErr = "" | |
| 418 | + | i.mu.Unlock() | |
| 419 | + | case "/unjail": | |
| 420 | + | if i.cfg.Sandbox == nil { | |
| 421 | + | i.mu.Lock() | |
| 422 | + | i.statusErr = "sandbox not available in this build" | |
| 423 | + | i.mu.Unlock() | |
| 424 | + | break | |
| 425 | + | } | |
| 426 | + | i.cfg.Sandbox.Unlock() | |
| 427 | + | i.mu.Lock() | |
| 428 | + | i.statusOK = "unjailed" | |
| 429 | + | i.statusErr = "" | |
| 430 | + | i.mu.Unlock() | |
| 431 | + | case "/reload-ext": | |
| 432 | + | i.runReloadExt(ctx) | |
| 433 | + | case "/telegram", "/tg": | |
| 434 | + | if len(parts) >= 2 { | |
| 435 | + | i.doTelegram(parts[1]) | |
| 436 | + | break | |
| 437 | + | } | |
| 438 | + | i.openTelegramDialog() | |
| 439 | + | case "/session": | |
| 440 | + | if len(parts) >= 2 { | |
| 441 | + | action := parts[1] | |
| 442 | + | arg := "" | |
| 443 | + | if len(parts) >= 3 { | |
| 444 | + | arg = strings.Join(parts[2:], " ") | |
| 445 | + | } | |
| 446 | + | i.doSessionOp(action, arg) | |
| 447 | + | break | |
| 448 | + | } | |
| 449 | + | i.openSessionOpsDialog() | |
| 450 | + | case "/subagents": | |
| 451 | + | i.runSubagents(ctx, parts[1:]) | |
| 452 | + | default: | |
| 453 | + | // Last-resort fallback: try the extension manager. Built-in | |
| 454 | + | // cases above always win; this branch only fires for slash | |
| 455 | + | // commands the extension manager registered. Same routing as | |
| 456 | + | // the editor's submit-handler dispatch path so the autocomplete | |
| 457 | + | // "enter on highlighted suggestion" flow also works. | |
| 458 | + | extName := strings.TrimPrefix(parts[0], "/") | |
| 459 | + | if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) { | |
| 460 | + | rest := "" | |
| 461 | + | if len(parts) > 1 { | |
| 462 | + | rest = strings.Join(parts[1:], " ") | |
| 463 | + | } | |
| 464 | + | go i.invokeExtensionCommand(ctx, extName, rest) | |
| 465 | + | return false | |
| 466 | + | } | |
| 467 | + | i.mu.Lock() | |
| 468 | + | i.statusErr = "unknown command: " + parts[0] | |
| 469 | + | i.mu.Unlock() | |
| 470 | + | } | |
| 471 | + | return false | |
| 472 | + | } | |
| 473 | + | func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) { | |
| 474 | + | var loggedIn []string | |
| 475 | + | if i.cfg.LoggedInProviders != nil { | |
| 476 | + | loggedIn = i.cfg.LoggedInProviders() | |
| 477 | + | } | |
| 478 | + | i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning) | |
| 479 | + | i.mu.Lock() | |
| 480 | + | if refreshErr != nil { | |
| 481 | + | i.statusErr = "llama.cpp model refresh: " + refreshErr.Error() | |
| 482 | + | i.statusOK = "" | |
| 483 | + | } else if i.statusOK == "Refreshing models" { | |
| 484 | + | i.statusOK = "" | |
| 485 | + | } | |
| 486 | + | i.mu.Unlock() | |
| 487 | + | } | |
| 488 | + | func (i *Interactive) openLogoutDialog() { | |
| 489 | + | if i.cfg.AuthManager == nil { | |
| 490 | + | i.mu.Lock() | |
| 491 | + | i.statusErr = "no auth manager configured" | |
| 492 | + | i.mu.Unlock() | |
| 493 | + | i.invalidate() | |
| 494 | + | return | |
| 495 | + | } | |
| 496 | + | store := i.cfg.AuthManager.Store() | |
| 497 | + | if store == nil { | |
| 498 | + | i.mu.Lock() | |
| 499 | + | i.statusErr = "auth store is not available" | |
| 500 | + | i.mu.Unlock() | |
| 501 | + | i.invalidate() | |
| 502 | + | return | |
| 503 | + | } | |
| 504 | + | creds, err := store.Load() | |
| 505 | + | if err != nil { | |
| 506 | + | i.mu.Lock() | |
| 507 | + | i.statusErr = "read auth store: " + err.Error() | |
| 508 | + | i.mu.Unlock() | |
| 509 | + | i.invalidate() | |
| 510 | + | return | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | var items []logoutItem | |
| 514 | + | for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} { | |
| 515 | + | if creds.Has(p) { | |
| 516 | + | method := creds.Method(p) | |
| 517 | + | if method == "oauth" { | |
| 518 | + | method = "subscription" | |
| 519 | + | } | |
| 520 | + | items = append(items, logoutItem{ | |
| 521 | + | label: providerLabel(p), | |
| 522 | + | target: p, | |
| 523 | + | method: method, | |
| 524 | + | }) | |
| 525 | + | } | |
| 526 | + | } | |
| 527 | + | if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil { | |
| 528 | + | items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"}) | |
| 529 | + | } | |
| 530 | + | if creds.OpenAI.OAuth != nil { | |
| 531 | + | items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"}) | |
| 532 | + | } | |
| 533 | + | for p, c := range creds.AdditionalAPIKeyCreds { | |
| 534 | + | if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" { | |
| 535 | + | items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"}) | |
| 536 | + | } | |
| 537 | + | } | |
| 538 | + | if len(items) == 0 { | |
| 539 | + | i.mu.Lock() | |
| 540 | + | i.statusOK = "no credentials stored; already logged out" | |
| 541 | + | i.statusErr = "" | |
| 542 | + | i.mu.Unlock() | |
| 543 | + | i.invalidate() | |
| 544 | + | return | |
| 545 | + | } | |
| 546 | + | if len(items) > 1 { | |
| 547 | + | items = append(items, logoutItem{label: "all", target: "all"}) | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | i.logoutDialog.Open(items) | |
| 551 | + | i.invalidate() | |
| 552 | + | } | |
| 553 | + | func (i *Interactive) doLogout(target string) { | |
| 554 | + | if i.cfg.AuthManager == nil { | |
| 555 | + | i.mu.Lock() | |
| 556 | + | i.statusErr = "no auth manager configured" | |
| 557 | + | i.mu.Unlock() | |
| 558 | + | return | |
| 559 | + | } | |
| 560 | + | store := i.cfg.AuthManager.Store() | |
| 561 | + | if store == nil { | |
| 562 | + | i.mu.Lock() | |
| 563 | + | i.statusErr = "auth store is not available" | |
| 564 | + | i.mu.Unlock() | |
| 565 | + | return | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | var providers []string | |
| 569 | + | switch target { | |
| 570 | + | case "", "all": | |
| 571 | + | providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...) | |
| 572 | + | case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot": | |
| 573 | + | providers = []string{target} | |
| 574 | + | default: | |
| 575 | + | known := false | |
| 576 | + | for _, p := range auth.APIKeyProviders() { | |
| 577 | + | if target == p { | |
| 578 | + | known = true | |
| 579 | + | break | |
| 580 | + | } | |
| 581 | + | } | |
| 582 | + | if !known { | |
| 583 | + | i.mu.Lock() | |
| 584 | + | i.statusErr = "unknown provider: " + target | |
| 585 | + | i.mu.Unlock() | |
| 586 | + | return | |
| 587 | + | } | |
| 588 | + | providers = []string{target} | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | var errs []string | |
| 592 | + | clearedCurrent := false | |
| 593 | + | for _, p := range providers { | |
| 594 | + | var err error | |
| 595 | + | switch p { | |
| 596 | + | case "openai": | |
| 597 | + | err = store.ClearAPIKey("openai") | |
| 598 | + | case "openai-codex": | |
| 599 | + | err = store.ClearOAuth("openai") | |
| 600 | + | default: | |
| 601 | + | err = store.Clear(p) | |
| 602 | + | } | |
| 603 | + | if err != nil { | |
| 604 | + | errs = append(errs, p+": "+err.Error()) | |
| 605 | + | continue | |
| 606 | + | } | |
| 607 | + | if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { | |
| 608 | + | if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil { | |
| 609 | + | errs = append(errs, p+": "+err.Error()) | |
| 610 | + | continue | |
| 611 | + | } | |
| 612 | + | } | |
| 613 | + | if p == i.cfg.Provider { | |
| 614 | + | clearedCurrent = true | |
| 615 | + | } | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | llamaConfigured := i.llamaConfigured | |
| 619 | + | if i.cfg.LlamaCPPConfig != nil { | |
| 620 | + | baseURL, _, err := i.cfg.LlamaCPPConfig() | |
| 621 | + | llamaConfigured = err == nil && baseURL != "" | |
| 622 | + | } | |
| 623 | + | ||
| 624 | + | i.mu.Lock() | |
| 625 | + | i.llamaConfigured = llamaConfigured | |
| 626 | + | if len(errs) > 0 { | |
| 627 | + | i.statusErr = "logout errors: " + strings.Join(errs, "; ") | |
| 628 | + | i.mu.Unlock() | |
| 629 | + | return | |
| 630 | + | } | |
| 631 | + | i.statusErr = "" | |
| 632 | + | var handoff json.RawMessage | |
| 633 | + | var persistHandoff bool | |
| 634 | + | if clearedCurrent { | |
| 635 | + | // The running agent was using a credential we just wiped. Drop | |
| 636 | + | // it so prompts can't go out with the stale client, and hint at | |
| 637 | + | // /login. Close any LSP processes owned by its tools first. | |
| 638 | + | if i.agent != nil { | |
| 639 | + | _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot()) | |
| 640 | + | } | |
| 641 | + | i.agent = nil | |
| 642 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 643 | + | i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in." | |
| 644 | + | } else { | |
| 645 | + | i.statusOK = "logged out of " + strings.Join(providers, ", ") | |
| 646 | + | } | |
| 647 | + | i.mu.Unlock() | |
| 648 | + | if persistHandoff { | |
| 649 | + | i.persistCompactHandoff(handoff) | |
| 650 | + | } | |
| 651 | + | } | |
| 652 | + | func providerSetupInfo(provider string) (string, []string, bool) { | |
| 653 | + | const docsURL = "https://raw.githubusercontent.com/bnema/zut/main/docs/providers.md" | |
| 654 | + | switch provider { | |
| 655 | + | case "amazon-bedrock": | |
| 656 | + | return "Amazon Bedrock setup", []string{ | |
| 657 | + | "Amazon Bedrock uses AWS credentials instead of a generic zut API-key entry.", | |
| 658 | + | "Configure an AWS profile, IAM keys, bearer token, or role-based credentials.", | |
| 659 | + | "", | |
| 660 | + | "For Bedrock API keys, set:", | |
| 661 | + | " AWS_BEARER_TOKEN_BEDROCK=...", | |
| 662 | + | " AWS_REGION=us-east-1", | |
| 663 | + | "", | |
| 664 | + | "Docs:", | |
| 665 | + | " " + docsURL, | |
| 666 | + | }, true | |
| 667 | + | case "google-vertex": | |
| 668 | + | return "Google Vertex AI setup", []string{ | |
| 669 | + | "Google Vertex AI usually uses Google Cloud credentials and project settings.", | |
| 670 | + | "Set a Google API key, application-default credentials, or a service account.", | |
| 671 | + | "", | |
| 672 | + | "Common environment:", | |
| 673 | + | " GOOGLE_CLOUD_API_KEY=...", | |
| 674 | + | " GOOGLE_CLOUD_PROJECT=...", | |
| 675 | + | " GOOGLE_CLOUD_LOCATION=us-central1", | |
| 676 | + | "", | |
| 677 | + | "Docs:", | |
| 678 | + | " " + docsURL, | |
| 679 | + | }, true | |
| 680 | + | case "cloudflare-workers-ai": | |
| 681 | + | return "Cloudflare Workers AI setup", []string{ | |
| 682 | + | "Cloudflare Workers AI needs both an API token and an account ID.", | |
| 683 | + | "", | |
| 684 | + | "Set:", | |
| 685 | + | " CLOUDFLARE_API_KEY=...", | |
| 686 | + | " CLOUDFLARE_ACCOUNT_ID=...", | |
| 687 | + | "", | |
| 688 | + | "Docs:", | |
| 689 | + | " " + docsURL, | |
| 690 | + | }, true | |
| 691 | + | case "cloudflare-ai-gateway": | |
| 692 | + | return "Cloudflare AI Gateway setup", []string{ | |
| 693 | + | "Cloudflare AI Gateway needs an API token, account ID, and gateway ID.", | |
| 694 | + | "", | |
| 695 | + | "Set:", | |
| 696 | + | " CLOUDFLARE_API_KEY=...", | |
| 697 | + | " CLOUDFLARE_ACCOUNT_ID=...", | |
| 698 | + | " CLOUDFLARE_GATEWAY_ID=...", | |
| 699 | + | "", | |
| 700 | + | "Docs:", | |
| 701 | + | " " + docsURL, | |
| 702 | + | }, true | |
| 703 | + | case "azure-openai-responses": | |
| 704 | + | return "Azure OpenAI Responses setup", []string{ | |
| 705 | + | "Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.", | |
| 706 | + | "", | |
| 707 | + | "Set:", | |
| 708 | + | " AZURE_OPENAI_API_KEY=...", | |
| 709 | + | " AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com", | |
| 710 | + | " AZURE_OPENAI_API_VERSION=2024-02-01", | |
| 711 | + | "", | |
| 712 | + | "Docs:", | |
| 713 | + | " " + docsURL, | |
| 714 | + | }, true | |
| 715 | + | default: | |
| 716 | + | return "", nil, false | |
| 717 | + | } | |
| 718 | + | } | |
| 719 | + | func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) { | |
| 720 | + | if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil { | |
| 721 | + | i.dialog.ShowResult(false, "auth store is unavailable") | |
| 722 | + | return | |
| 723 | + | } | |
| 724 | + | if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil { | |
| 725 | + | i.dialog.ShowResult(false, err.Error()) | |
| 726 | + | return | |
| 727 | + | } | |
| 728 | + | i.mu.Lock() | |
| 729 | + | i.llamaConfigured = true | |
| 730 | + | i.statusErr = "" | |
| 731 | + | i.statusOK = "configured llama.cpp router" | |
| 732 | + | i.mu.Unlock() | |
| 733 | + | i.dialog.ShowResult(true, "") | |
| 734 | + | } | |
| 735 | + | func (i *Interactive) startAPIKeyFlow(provider string) { | |
| 736 | + | if title, lines, ok := providerSetupInfo(provider); ok { | |
| 737 | + | i.dialog.ShowInfo(title, lines) | |
| 738 | + | return | |
| 739 | + | } | |
| 740 | + | if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { | |
| 741 | + | _ = i.cfg.SetKimiCLIFallbackDisabled(false) | |
| 742 | + | } | |
| 743 | + | url, err := i.cfg.AuthManager.StartAPIKey(provider) | |
| 744 | + | if err != nil { | |
| 745 | + | i.dialog.ShowResult(false, err.Error()) | |
| 746 | + | return | |
| 747 | + | } | |
| 748 | + | i.dialog.ShowWaiting(url) | |
| 749 | + | } | |
| 750 | + | func (i *Interactive) startOAuthFlow(provider string) { | |
| 751 | + | if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { | |
| 752 | + | _ = i.cfg.SetKimiCLIFallbackDisabled(false) | |
| 753 | + | } | |
| 754 | + | // Device-code providers already support headless login and must only | |
| 755 | + | // start one polling flow. | |
| 756 | + | if provider == "kimi" || provider == "xai" || provider == "github-copilot" { | |
| 757 | + | loginURL, err := i.cfg.AuthManager.StartOAuth(provider) | |
| 758 | + | if err != nil { | |
| 759 | + | i.dialog.ShowResult(false, err.Error()) | |
| 760 | + | return | |
| 761 | + | } | |
| 762 | + | i.dialog.ShowWaiting(loginURL) | |
| 763 | + | return | |
| 764 | + | } | |
| 765 | + | // Always run the manual/copy-code flow in parallel with the local | |
| 766 | + | // callback server so headless environments (docker, SSH) can paste | |
| 767 | + | // the authorization code directly without first pressing 'p'. | |
| 768 | + | _, err := i.cfg.AuthManager.StartOAuth(provider) | |
| 769 | + | if err != nil { | |
| 770 | + | i.dialog.ShowResult(false, err.Error()) | |
| 771 | + | return | |
| 772 | + | } | |
| 773 | + | manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider) | |
| 774 | + | if mErr == nil { | |
| 775 | + | i.dialog.ShowWaiting(manualURL) | |
| 776 | + | } else { | |
| 777 | + | i.dialog.ShowResult(false, mErr.Error()) | |
| 778 | + | } | |
| 779 | + | } | |
| 780 | + | func (i *Interactive) startManualOAuthFlow(provider string) { | |
| 781 | + | if i.cfg.AuthManager == nil { | |
| 782 | + | return | |
| 783 | + | } | |
| 784 | + | i.cfg.AuthManager.CancelOAuth() | |
| 785 | + | url, err := i.cfg.AuthManager.StartManualOAuth(provider) | |
| 786 | + | if err != nil { | |
| 787 | + | i.dialog.ShowResult(false, err.Error()) | |
| 788 | + | return | |
| 789 | + | } | |
| 790 | + | i.dialog.url = url | |
| 791 | + | i.invalidate() | |
| 792 | + | } | |
| 793 | + | func (i *Interactive) submitManualOAuthCode(code string) { | |
| 794 | + | if i.cfg.AuthManager == nil { | |
| 795 | + | return | |
| 796 | + | } | |
| 797 | + | go func() { | |
| 798 | + | if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil { | |
| 799 | + | i.dialog.ShowResult(false, err.Error()) | |
| 800 | + | i.invalidate() | |
| 801 | + | } | |
| 802 | + | }() | |
| 803 | + | } | |
| 804 | + | func (i *Interactive) cancelAndWaitForIdle() { | |
| 805 | + | i.mu.Lock() | |
| 806 | + | busy := i.busy | |
| 807 | + | cancel := i.cancelTurn | |
| 808 | + | i.mu.Unlock() | |
| 809 | + | if !busy { | |
| 810 | + | return | |
| 811 | + | } | |
| 812 | + | if cancel != nil { | |
| 813 | + | i.mu.Lock() | |
| 814 | + | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 815 | + | i.mu.Unlock() | |
| 816 | + | cancel() | |
| 817 | + | if persistHandoff { | |
| 818 | + | i.persistCompactHandoff(handoff) | |
| 819 | + | } | |
| 820 | + | } | |
| 821 | + | // ConfirmToolCall waits on its response channel rather than the turn | |
| 822 | + | // context, so cancellation must also resolve every pending prompt. | |
| 823 | + | i.confirmDialog.CancelAll("turn cancelled") | |
| 824 | + | deadline := time.Now().Add(2 * time.Second) | |
| 825 | + | for time.Now().Before(deadline) { | |
| 826 | + | i.mu.Lock() | |
| 827 | + | done := !i.busy | |
| 828 | + | i.mu.Unlock() | |
| 829 | + | if done { | |
| 830 | + | return | |
| 831 | + | } | |
| 832 | + | time.Sleep(10 * time.Millisecond) | |
| 833 | + | } | |
| 834 | + | } | |
| 835 | + | func (i *Interactive) openBtwDialog(args []string) { | |
| 836 | + | i.agentMu.Lock() | |
| 837 | + | defer i.agentMu.Unlock() | |
| 838 | + | i.mu.Lock() | |
| 839 | + | ag := i.agent | |
| 840 | + | system := "" | |
| 841 | + | if ag != nil { | |
| 842 | + | system, _ = ag.PromptConfig() | |
| 843 | + | } | |
| 844 | + | theme := i.cfg.Theme | |
| 845 | + | model := i.cfg.Model | |
| 846 | + | cwd := i.cfg.CWD | |
| 847 | + | compact := i.compactModeEnabled() | |
| 848 | + | flatTools := i.cfg.FlatTools | |
| 849 | + | lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines | |
| 850 | + | i.mu.Unlock() | |
| 851 | + | if ag == nil { | |
| 852 | + | i.mu.Lock() | |
| 853 | + | i.statusErr = "not logged in. type /login first." | |
| 854 | + | i.mu.Unlock() | |
| 855 | + | return | |
| 856 | + | } | |
| 857 | + | seed := strings.TrimSpace(strings.Join(args, " ")) | |
| 858 | + | i.btwDialog.Open(theme, ag, system, model, cwd, seed, compact, flatTools, lineInput, i.invalidate) | |
| 859 | + | i.invalidate() | |
| 860 | + | } | |
| 861 | + | func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) { | |
| 862 | + | i.mu.Lock() | |
| 863 | + | if i.agent == nil { | |
| 864 | + | i.statusErr = "not logged in. type /login first." | |
| 865 | + | i.statusOK = "" | |
| 866 | + | i.mu.Unlock() | |
| 867 | + | i.invalidate() | |
| 868 | + | return | |
| 869 | + | } | |
| 870 | + | if i.busy { | |
| 871 | + | // Keep the mutex held while enqueueing so the turn-completion | |
| 872 | + | // goroutine cannot publish idle and inspect an empty queue between | |
| 873 | + | // this check and the append. | |
| 874 | + | var handoff json.RawMessage | |
| 875 | + | var persistHandoff bool | |
| 876 | + | if i.compacting { | |
| 877 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 878 | + | i.queued = append(i.queued, prompt) | |
| 879 | + | } else { | |
| 880 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 881 | + | i.agent.QueueMessage(prompt) | |
| 882 | + | } | |
| 883 | + | i.mu.Unlock() | |
| 884 | + | if persistHandoff { | |
| 885 | + | i.persistCompactHandoff(handoff) | |
| 886 | + | } | |
| 887 | + | i.invalidate() | |
| 888 | + | return | |
| 889 | + | } | |
| 890 | + | i.mu.Unlock() | |
| 891 | + | i.startTurn(ctx, prompt) | |
| 892 | + | } | |
| 893 | + | func (i *Interactive) openSkillsDialog() { | |
| 894 | + | var list []*skills.Skill | |
| 895 | + | if i.cfg.SkillSnapshot != nil { | |
| 896 | + | list = i.cfg.SkillSnapshot() | |
| 897 | + | } | |
| 898 | + | i.skillsDialog.Open(list) | |
| 899 | + | i.invalidate() | |
| 900 | + | } | |
| 901 | + | func (i *Interactive) openJumpDialog(args []string) { | |
| 902 | + | if i.view == nil || len(i.view.Messages) == 0 { | |
| 903 | + | i.mu.Lock() | |
| 904 | + | i.statusErr = "nothing to jump to \u2014 the session is empty" | |
| 905 | + | i.mu.Unlock() | |
| 906 | + | return | |
| 907 | + | } | |
| 908 | + | filter := strings.TrimSpace(strings.Join(args, " ")) | |
| 909 | + | i.jumpDialog.Open(i.view.Messages, filter) | |
| 910 | + | // Shortcut: with a filter argument that matches exactly one turn, | |
| 911 | + | // jump immediately and skip the picker. | |
| 912 | + | if filter != "" { | |
| 913 | + | if tgts := i.jumpDialog.Targets(); len(tgts) == 1 { | |
| 914 | + | t := tgts[0] | |
| 915 | + | i.jumpDialog.Close() | |
| 916 | + | i.applyJumpSelection(t.MessageIdx, t.TurnNo) | |
| 917 | + | } | |
| 918 | + | } | |
| 919 | + | } | |
| 920 | + | func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) { | |
| 921 | + | cols := i.lastCols() | |
| 922 | + | chat, anchors := i.view.BuildWithAnchors(cols) | |
| 923 | + | var row int | |
| 924 | + | found := false | |
| 925 | + | for _, a := range anchors { | |
| 926 | + | if a.MessageIdx == msgIdx { | |
| 927 | + | row = a.Row | |
| 928 | + | found = true | |
| 929 | + | break | |
| 930 | + | } | |
| 931 | + | } | |
| 932 | + | if !found { | |
| 933 | + | i.mu.Lock() | |
| 934 | + | i.statusErr = "could not resolve jump target" | |
| 935 | + | i.mu.Unlock() | |
| 936 | + | return | |
| 937 | + | } | |
| 938 | + | ||
| 939 | + | chatLen := len(chat) | |
| 940 | + | page := i.chatPage() | |
| 941 | + | if page < 1 { | |
| 942 | + | page = 1 | |
| 943 | + | } | |
| 944 | + | // scrollOffset is measured from the bottom of the chat slice, so | |
| 945 | + | // to place `row` at the top of the viewport we want: | |
| 946 | + | // chatLen - scrollOffset - page == row | |
| 947 | + | // Solve for scrollOffset and clamp to [0, chatLen-page]. | |
| 948 | + | offset := chatLen - (row + page) | |
| 949 | + | if offset < 0 { | |
| 950 | + | offset = 0 | |
| 951 | + | } | |
| 952 | + | maxOffset := chatLen - page | |
| 953 | + | if maxOffset < 0 { | |
| 954 | + | maxOffset = 0 | |
| 955 | + | } | |
| 956 | + | if offset > maxOffset { | |
| 957 | + | offset = maxOffset | |
| 958 | + | } | |
| 959 | + | ||
| 960 | + | i.mu.Lock() | |
| 961 | + | i.scrollOffset = offset | |
| 962 | + | i.parkedTurn = turnNo | |
| 963 | + | i.parkedTotal = totalTurnsLocked(i.view.Messages) | |
| 964 | + | i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo) | |
| 965 | + | i.statusErr = "" | |
| 966 | + | i.mu.Unlock() | |
| 967 | + | } | |
| 968 | + | func totalTurnsLocked(msgs []provider.Message) int { | |
| 969 | + | n := 0 | |
| 970 | + | for _, m := range msgs { | |
| 971 | + | if m.Role == provider.RoleUser { | |
| 972 | + | n++ | |
| 973 | + | } | |
| 974 | + | } | |
| 975 | + | return n | |
| 976 | + | } | |
| 977 | + | func (i *Interactive) applySessionSelection(path string) { | |
| 978 | + | if i.cfg.LoadSession == nil { | |
| 979 | + | i.mu.Lock() | |
| 980 | + | i.statusErr = "session loading is not wired in this build" | |
| 981 | + | i.mu.Unlock() | |
| 982 | + | return | |
| 983 | + | } | |
| 984 | + | i.mu.Lock() | |
| 985 | + | if i.sessionLoading { | |
| 986 | + | i.statusErr = "already resuming a session" | |
| 987 | + | i.mu.Unlock() | |
| 988 | + | i.invalidate() | |
| 989 | + | return | |
| 990 | + | } | |
| 991 | + | i.sessionLoading = true | |
| 992 | + | i.statusOK = "resuming session: " + path | |
| 993 | + | i.statusErr = "" | |
| 994 | + | i.mu.Unlock() | |
| 995 | + | i.invalidate() | |
| 996 | + | i.markSessionTitleSwitching() | |
| 997 | + | ||
| 998 | + | go func() { | |
| 999 | + | err := i.cfg.LoadSession(path) | |
| 1000 | + | if err != nil { | |
| 1001 | + | i.restoreFailedSessionTitle() | |
| 1002 | + | i.mu.Lock() | |
| 1003 | + | i.sessionLoading = false | |
| 1004 | + | i.statusErr = err.Error() | |
| 1005 | + | i.statusOK = "" | |
| 1006 | + | i.mu.Unlock() | |
| 1007 | + | i.invalidate() | |
| 1008 | + | return | |
| 1009 | + | } | |
| 1010 | + | i.restoreLoadedSessionTitle() | |
| 1011 | + | state := decodeCompactHandoff(i.currentCompactHandoff()) | |
| 1012 | + | i.mu.Lock() | |
| 1013 | + | i.compactContinuation = state | |
| 1014 | + | i.sessionLoading = false | |
| 1015 | + | i.statusOK = "resumed session: " + path | |
| 1016 | + | i.statusErr = "" | |
| 1017 | + | i.parkedTurn = 0 | |
| 1018 | + | i.parkedTotal = 0 | |
| 1019 | + | i.scrollOffset = 0 | |
| 1020 | + | // Fresh transcript swapped in: drop the auto-follow baseline so | |
| 1021 | + | // the next render's follow guard doesn't see the wholesale | |
| 1022 | + | // length change as a delta and jump the viewport. | |
| 1023 | + | i.prevChatLen = 0 | |
| 1024 | + | i.prevChatCols = 0 | |
| 1025 | + | i.extNotes = nil | |
| 1026 | + | i.view.InvalidateRenderCache() | |
| 1027 | + | if i.agent != nil { | |
| 1028 | + | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |
| 1029 | + | i.cumUsage = i.agent.Cost() | |
| 1030 | + | if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 { | |
| 1031 | + | i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens | |
| 1032 | + | } else { | |
| 1033 | + | i.lastCtxInput = 0 | |
| 1034 | + | } | |
| 1035 | + | // Snap to the tail again — the swap brought in a fresh | |
| 1036 | + | // transcript whose markdown / chroma cost we don't want | |
| 1037 | + | // blocking the redraw. | |
| 1038 | + | if len(i.view.Messages) > initialResumeTailLimit { | |
| 1039 | + | i.view.TailLimit = initialResumeTailLimit | |
| 1040 | + | } else { | |
| 1041 | + | i.view.TailLimit = 0 | |
| 1042 | + | } | |
| 1043 | + | } | |
| 1044 | + | i.mu.Unlock() | |
| 1045 | + | i.invalidate() | |
| 1046 | + | if state.reason != compactContinuationNone { | |
| 1047 | + | i.startRestoredCompactHandoff(i.runCtx) | |
| 1048 | + | } | |
| 1049 | + | }() | |
| 1050 | + | } | |
| 1051 | + | func (i *Interactive) applyModelSelection(prov, model string) { | |
| 1052 | + | i.swapModel(prov, model, i.cfg.BuildAgentFor, false) | |
| 1053 | + | } | |
| 1054 | + | func (i *Interactive) applyRescueModelSelection(prov, model string) { | |
| 1055 | + | builder := i.cfg.BuildAgentForRescue | |
| 1056 | + | if builder == nil { | |
| 1057 | + | builder = i.cfg.BuildAgentFor | |
| 1058 | + | } | |
| 1059 | + | i.swapModel(prov, model, builder, true) | |
| 1060 | + | } | |
| 1061 | + | func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) { | |
| 1062 | + | var replaced bool | |
| 1063 | + | swap := func() { | |
| 1064 | + | replaced = i.swapModelUnserialized(prov, model, builder, rescue) | |
| 1065 | + | } | |
| 1066 | + | if i.cfg.SessionTransition != nil { | |
| 1067 | + | i.cfg.SessionTransition(swap) | |
| 1068 | + | } else { | |
| 1069 | + | swap() | |
| 1070 | + | } | |
| 1071 | + | if replaced { | |
| 1072 | + | i.resetCompactHandoff() | |
| 1073 | + | } | |
| 1074 | + | } | |
| 1075 | + | func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) bool { | |
| 1076 | + | if model == "" { | |
| 1077 | + | return false | |
| 1078 | + | } | |
| 1079 | + | m, err := provider.FindModel(prov, model) | |
| 1080 | + | if err != nil { | |
| 1081 | + | i.mu.Lock() | |
| 1082 | + | i.statusErr = err.Error() | |
| 1083 | + | i.mu.Unlock() | |
| 1084 | + | return false | |
| 1085 | + | } | |
| 1086 | + | // Same provider AND not a rescue retry: just swap the model on | |
| 1087 | + | // the existing agent. Mixed-API providers dispatch from model metadata, | |
| 1088 | + | // so the client remains reusable. Rescue retries always rebuild so a stale | |
| 1089 | + | // auth header / base URL can't carry over. | |
| 1090 | + | if !rescue && i.agent != nil && m.Provider == i.cfg.Provider { | |
| 1091 | + | i.mu.Lock() | |
| 1092 | + | i.cfg.Model = m.ID | |
| 1093 | + | i.agent.Model = m.ID | |
| 1094 | + | i.statusOK = "model: " + m.ID | |
| 1095 | + | i.statusErr = "" | |
| 1096 | + | i.mu.Unlock() | |
| 1097 | + | if i.cfg.PersistModel != nil { | |
| 1098 | + | i.cfg.PersistModel(i.cfg.Provider, m.ID) | |
| 1099 | + | } | |
| 1100 | + | return false | |
| 1101 | + | } | |
| 1102 | + | if builder == nil { | |
| 1103 | + | i.mu.Lock() | |
| 1104 | + | i.statusErr = "cannot switch provider: no builder configured" | |
| 1105 | + | i.mu.Unlock() | |
| 1106 | + | return false | |
| 1107 | + | } | |
| 1108 | + | // Snapshot the current transcript and cumulative usage BEFORE we | |
| 1109 | + | // build the replacement agent so we can hand them off. Without | |
| 1110 | + | // this the user perceives the entire session as wiped on a | |
| 1111 | + | // cross-provider /model swap. | |
| 1112 | + | var carryMsgs []provider.Message | |
| 1113 | + | var carryCost provider.Usage | |
| 1114 | + | if i.agent != nil { | |
| 1115 | + | carryMsgs = i.agent.Messages() | |
| 1116 | + | carryCost = i.agent.Cost() | |
| 1117 | + | } | |
| 1118 | + | ||
| 1119 | + | ag, p, md, err := builder(m.Provider, m.ID) | |
| 1120 | + | if err != nil { | |
| 1121 | + | i.mu.Lock() | |
| 1122 | + | i.statusErr = err.Error() | |
| 1123 | + | i.mu.Unlock() | |
| 1124 | + | return false | |
| 1125 | + | } | |
| 1126 | + | ||
| 1127 | + | // Replay the transcript and seed the cost on the freshly-built | |
| 1128 | + | // agent. Messages travel cleanly between providers because they | |
| 1129 | + | // use the same provider.Message shape; tool-call ids are local | |
| 1130 | + | // to a turn so cross-provider continuation never confuses the | |
| 1131 | + | // new model (it just sees the assistant's reply, no orphan | |
| 1132 | + | // tool_use blocks because /model swaps are gated to idle state). | |
| 1133 | + | if len(carryMsgs) > 0 { | |
| 1134 | + | ag.SetMessages(carryMsgs) | |
| 1135 | + | } | |
| 1136 | + | ag.SeedCost(carryCost) | |
| 1137 | + | ||
| 1138 | + | if i.agent != nil && i.agent != ag { | |
| 1139 | + | _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot()) | |
| 1140 | + | } | |
| 1141 | + | i.agentMu.Lock() | |
| 1142 | + | i.mu.Lock() | |
| 1143 | + | i.prepareReplacementAgentLocked(ag) | |
| 1144 | + | i.agent = ag | |
| 1145 | + | i.cfg.Provider = p | |
| 1146 | + | i.cfg.Model = md | |
| 1147 | + | if rescue { | |
| 1148 | + | i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)" | |
| 1149 | + | } else { | |
| 1150 | + | i.statusOK = "switched to " + p + " / " + md | |
| 1151 | + | } | |
| 1152 | + | i.statusErr = "" | |
| 1153 | + | // Render cache keys are width+content based, so the new agent's | |
| 1154 | + | // identical messages will reuse the existing entries. Nothing | |
| 1155 | + | // to invalidate. | |
| 1156 | + | i.mu.Unlock() | |
| 1157 | + | i.agentMu.Unlock() | |
| 1158 | + | // The new agent was built off the base tool registry, so any | |
| 1159 | + | // dynamically-registered tools need to be reattached. The apply | |
| 1160 | + | // helpers are no-ops when their feature is inactive, so the | |
| 1161 | + | // cross-provider path still works on a vanilla setup. | |
| 1162 | + | i.applyAutoSubagentsTool() | |
| 1163 | + | i.applyTelegramTools(i.telegramBridge != nil) | |
| 1164 | + | if i.cfg.PersistModel != nil { | |
| 1165 | + | i.cfg.PersistModel(p, md) | |
| 1166 | + | } | |
| 1167 | + | return true | |
| 1168 | + | } | |
| 1169 | + | func (i *Interactive) handleAuthEvent(ev auth.Event) { | |
| 1170 | + | switch ev.Kind { | |
| 1171 | + | case "started": | |
| 1172 | + | i.dialog.ShowWaiting(ev.URL) | |
| 1173 | + | case "browser_open": | |
| 1174 | + | // no-op | |
| 1175 | + | case "error": | |
| 1176 | + | i.dialog.ShowResult(false, ev.Message) | |
| 1177 | + | case "success": | |
| 1178 | + | // Keep the credential-driven resolve and its final replacement in the | |
| 1179 | + | // same transition as settings/session changes. Otherwise a build that | |
| 1180 | + | // started under the old web-search policy could commit afterward. | |
| 1181 | + | var buildErr error | |
| 1182 | + | var clearHandoff bool | |
| 1183 | + | buildAndCommitLogin := func() { | |
| 1184 | + | ag, prov, model, err := i.cfg.BuildAgent() | |
| 1185 | + | if err != nil { | |
| 1186 | + | buildErr = err | |
| 1187 | + | return | |
| 1188 | + | } | |
| 1189 | + | i.agentMu.Lock() | |
| 1190 | + | i.mu.Lock() | |
| 1191 | + | oldAgent := i.agent | |
| 1192 | + | i.prepareReplacementAgentLocked(ag) | |
| 1193 | + | _, clearHandoff = i.resetCompactContinuationLocked() | |
| 1194 | + | i.agent = ag | |
| 1195 | + | i.cfg.Provider = prov | |
| 1196 | + | i.cfg.Model = model | |
| 1197 | + | i.statusErr = "" | |
| 1198 | + | i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method | |
| 1199 | + | i.mu.Unlock() | |
| 1200 | + | i.agentMu.Unlock() | |
| 1201 | + | if oldAgent != nil { | |
| 1202 | + | _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot()) | |
| 1203 | + | } | |
| 1204 | + | i.applyAutoSubagentsTool() | |
| 1205 | + | i.applyTelegramTools(i.telegramBridge != nil) | |
| 1206 | + | // Authentication can change the provider used by the live | |
| 1207 | + | // agent. Persist it on the active session just like /model. | |
| 1208 | + | if i.cfg.PersistModel != nil { | |
| 1209 | + | i.cfg.PersistModel(prov, model) | |
| 1210 | + | } | |
| 1211 | + | } | |
| 1212 | + | if i.cfg.SessionTransition != nil { | |
| 1213 | + | i.cfg.SessionTransition(buildAndCommitLogin) | |
| 1214 | + | } else { | |
| 1215 | + | buildAndCommitLogin() | |
| 1216 | + | } | |
| 1217 | + | if buildErr != nil { | |
| 1218 | + | i.dialog.ShowResult(false, buildErr.Error()) | |
| 1219 | + | return | |
| 1220 | + | } | |
| 1221 | + | if clearHandoff { | |
| 1222 | + | i.persistCompactHandoff(nil) | |
| 1223 | + | } | |
| 1224 | + | i.dialog.ShowResult(true, "") | |
| 1225 | + | } | |
| 1226 | + | } | |
A packages/agent/modes/interactive_compaction.go +400 -0
| @@ -0,0 +1,400 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zut/packages/provider" | |
| 9 | + | "github.com/bnema/zut/packages/tui" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func (i *Interactive) clearPendingCompactTurnLocked() { | |
| 13 | + | i.pendingCompactPrompt = "" | |
| 14 | + | i.pendingCompactImages = nil | |
| 15 | + | i.hasPendingCompactPrompt = false | |
| 16 | + | i.continueAfterCompact = false | |
| 17 | + | i.pendingPostCompactNote = "" | |
| 18 | + | } | |
| 19 | + | func (i *Interactive) compactHandoffLocked() json.RawMessage { | |
| 20 | + | return encodeCompactHandoff(i.compactContinuation) | |
| 21 | + | } | |
| 22 | + | func (i *Interactive) setCompactContinuationLocked(state compactContinuationState) (json.RawMessage, bool) { | |
| 23 | + | if i.compactContinuation == state { | |
| 24 | + | return i.compactHandoffLocked(), false | |
| 25 | + | } | |
| 26 | + | i.compactContinuation = state | |
| 27 | + | return i.compactHandoffLocked(), true | |
| 28 | + | } | |
| 29 | + | func (i *Interactive) resetCompactContinuationLocked() (json.RawMessage, bool) { | |
| 30 | + | return i.setCompactContinuationLocked(compactContinuationState{}) | |
| 31 | + | } | |
| 32 | + | func (i *Interactive) persistCompactHandoff(state json.RawMessage) { | |
| 33 | + | if persist := i.cfg.PersistCompactHandoff; persist != nil { | |
| 34 | + | if err := persist(state); err != nil { | |
| 35 | + | i.ReportError(fmt.Errorf("persist compact handoff: %w", err)) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | func (i *Interactive) resetCompactHandoff() { | |
| 40 | + | i.mu.Lock() | |
| 41 | + | state, persist := i.resetCompactContinuationLocked() | |
| 42 | + | i.mu.Unlock() | |
| 43 | + | if persist { | |
| 44 | + | i.persistCompactHandoff(state) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | func (i *Interactive) currentCompactHandoff() json.RawMessage { | |
| 48 | + | if current := i.cfg.CurrentCompactHandoff; current != nil { | |
| 49 | + | return current() | |
| 50 | + | } | |
| 51 | + | return nil | |
| 52 | + | } | |
| 53 | + | func (i *Interactive) restoreCompactHandoff(state compactContinuationState) { | |
| 54 | + | i.mu.Lock() | |
| 55 | + | i.compactContinuation = state | |
| 56 | + | i.mu.Unlock() | |
| 57 | + | } | |
| 58 | + | func (i *Interactive) restoreCurrentCompactHandoff() compactContinuationState { | |
| 59 | + | state := decodeCompactHandoff(i.currentCompactHandoff()) | |
| 60 | + | i.restoreCompactHandoff(state) | |
| 61 | + | return state | |
| 62 | + | } | |
| 63 | + | func classifyCompactHandoffResume(messages []provider.Message) compactHandoffResume { | |
| 64 | + | lastHandoff := -1 | |
| 65 | + | for idx := len(messages) - 1; idx >= 0; idx-- { | |
| 66 | + | message := messages[idx] | |
| 67 | + | if message.Role == provider.RoleUser && message.Meta[autoCompactContinueMetaKey] == "true" { | |
| 68 | + | lastHandoff = idx | |
| 69 | + | break | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | if lastHandoff < 0 { | |
| 73 | + | return compactHandoffAppendPrompt | |
| 74 | + | } | |
| 75 | + | for _, message := range messages[lastHandoff+1:] { | |
| 76 | + | switch message.Role { | |
| 77 | + | case provider.RoleTool: | |
| 78 | + | continue | |
| 79 | + | case provider.RoleAssistant: | |
| 80 | + | hasToolCall := false | |
| 81 | + | for _, content := range message.Content { | |
| 82 | + | if _, ok := content.(provider.ToolCallBlock); ok { | |
| 83 | + | hasToolCall = true | |
| 84 | + | break | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | if !hasToolCall { | |
| 88 | + | return compactHandoffDiscard | |
| 89 | + | } | |
| 90 | + | default: | |
| 91 | + | return compactHandoffDiscard | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | return compactHandoffContinueExisting | |
| 95 | + | } | |
| 96 | + | func (i *Interactive) startRestoredCompactHandoff(parent context.Context) { | |
| 97 | + | if parent == nil { | |
| 98 | + | parent = i.runCtx | |
| 99 | + | } | |
| 100 | + | if parent == nil { | |
| 101 | + | parent = context.Background() | |
| 102 | + | } | |
| 103 | + | i.mu.Lock() | |
| 104 | + | state := i.compactContinuation | |
| 105 | + | ag := i.agent | |
| 106 | + | busy := i.busy || i.compacting || i.autoCompacting || i.shellRunning | |
| 107 | + | if state.reason == compactContinuationNone || ag == nil || busy { | |
| 108 | + | i.mu.Unlock() | |
| 109 | + | return | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | var next string | |
| 113 | + | continueQueued := false | |
| 114 | + | var resume compactHandoffResume | |
| 115 | + | var handoff json.RawMessage | |
| 116 | + | var persistHandoff bool | |
| 117 | + | switch { | |
| 118 | + | case state.reason != compactContinuationForcedLength && len(i.queued) > 0: | |
| 119 | + | next, i.queued = i.queued[0], i.queued[1:] | |
| 120 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 121 | + | case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0: | |
| 122 | + | continueQueued = true | |
| 123 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 124 | + | default: | |
| 125 | + | resume = classifyCompactHandoffResume(ag.Messages()) | |
| 126 | + | if resume == compactHandoffDiscard { | |
| 127 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | // Reserve the turn slot before persisting or dispatching so concurrent input | |
| 131 | + | // can only queue behind the restored handoff, never start a competing turn. | |
| 132 | + | starting := next != "" || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt | |
| 133 | + | i.busy = starting | |
| 134 | + | i.mu.Unlock() | |
| 135 | + | if persistHandoff { | |
| 136 | + | i.persistCompactHandoff(handoff) | |
| 137 | + | } | |
| 138 | + | if !starting { | |
| 139 | + | i.invalidate() | |
| 140 | + | return | |
| 141 | + | } | |
| 142 | + | switch { | |
| 143 | + | case next != "": | |
| 144 | + | i.startTurn(parent, next) | |
| 145 | + | case continueQueued: | |
| 146 | + | i.startTurnRequest(parent, "", nil, true, false) | |
| 147 | + | case resume == compactHandoffContinueExisting: | |
| 148 | + | i.startTurnRequest(parent, "", nil, true, false) | |
| 149 | + | case resume == compactHandoffAppendPrompt: | |
| 150 | + | i.startAutoCompactContinuation(parent) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | func (i *Interactive) startAutoCompactContinuation(parent context.Context) { | |
| 154 | + | i.mu.Lock() | |
| 155 | + | ag := i.agent | |
| 156 | + | if ag == nil { | |
| 157 | + | i.busy = false | |
| 158 | + | pendingIdleWork := i.takePendingIdleWorkLocked() | |
| 159 | + | i.mu.Unlock() | |
| 160 | + | runPendingIdleWork(pendingIdleWork) | |
| 161 | + | i.invalidate() | |
| 162 | + | return | |
| 163 | + | } | |
| 164 | + | // The caller selected the continuation while holding i.mu. Keep that | |
| 165 | + | // ordering through the hidden append so an explicitly queued user prompt | |
| 166 | + | // cannot be overtaken by an old-task handoff. Forced truncation is the | |
| 167 | + | // existing exception: it keeps priority and the queued prompt runs after it. | |
| 168 | + | if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 { | |
| 169 | + | next := i.queued[0] | |
| 170 | + | i.queued = i.queued[1:] | |
| 171 | + | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 172 | + | i.mu.Unlock() | |
| 173 | + | if persistHandoff { | |
| 174 | + | i.persistCompactHandoff(handoff) | |
| 175 | + | } | |
| 176 | + | i.startTurn(parent, next) | |
| 177 | + | return | |
| 178 | + | } | |
| 179 | + | if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 { | |
| 180 | + | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 181 | + | i.mu.Unlock() | |
| 182 | + | if persistHandoff { | |
| 183 | + | i.persistCompactHandoff(handoff) | |
| 184 | + | } | |
| 185 | + | i.startTurnRequest(parent, "", nil, true, false) | |
| 186 | + | return | |
| 187 | + | } | |
| 188 | + | ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{ | |
| 189 | + | autoCompactContinueMetaKey: "true", | |
| 190 | + | }) | |
| 191 | + | i.mu.Unlock() | |
| 192 | + | i.startTurnRequest(parent, "", nil, true, false) | |
| 193 | + | } | |
| 194 | + | func (i *Interactive) runCompact(parent context.Context, request compactContinuationRequest) { | |
| 195 | + | auto := request.origin != compactOriginManual | |
| 196 | + | if i.agent == nil { | |
| 197 | + | i.mu.Lock() | |
| 198 | + | i.statusErr = "not logged in. type /login first." | |
| 199 | + | i.mu.Unlock() | |
| 200 | + | return | |
| 201 | + | } | |
| 202 | + | ctx, cancel := context.WithCancel(parent) | |
| 203 | + | i.mu.Lock() | |
| 204 | + | i.busy = true | |
| 205 | + | i.compacting = true | |
| 206 | + | i.spin.Start() | |
| 207 | + | i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model) | |
| 208 | + | if auto { | |
| 209 | + | i.activity.kind = activityCondensingHistory | |
| 210 | + | i.autoCompacting = true | |
| 211 | + | } else { | |
| 212 | + | i.activity.kind = activityCompactingHistory | |
| 213 | + | } | |
| 214 | + | i.cancelTurn = cancel | |
| 215 | + | var initialHandoff json.RawMessage | |
| 216 | + | persistInitialHandoff := false | |
| 217 | + | if request.origin == compactOriginManual || request.origin == compactOriginRecovery { | |
| 218 | + | initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked() | |
| 219 | + | } | |
| 220 | + | i.statusErr = "" | |
| 221 | + | i.statusOK = "" | |
| 222 | + | // Do NOT set streamOn: the summary text should not be visible | |
| 223 | + | // in the chat while compacting. The user just sees the spinner | |
| 224 | + | // and can keep typing / queue prompts. | |
| 225 | + | i.scrollOffset = 0 | |
| 226 | + | i.helpBlock = nil | |
| 227 | + | i.mu.Unlock() | |
| 228 | + | if persistInitialHandoff { | |
| 229 | + | i.persistCompactHandoff(initialHandoff) | |
| 230 | + | } | |
| 231 | + | i.invalidate() | |
| 232 | + | ||
| 233 | + | go func() { | |
| 234 | + | // Sink discards deltas — we don't stream the summary to the UI. | |
| 235 | + | sink := func(delta string) {} | |
| 236 | + | msgsBefore := i.agent.Messages() | |
| 237 | + | i.mu.Lock() | |
| 238 | + | statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue | |
| 239 | + | i.mu.Unlock() | |
| 240 | + | continuationReason := classifyCompactionContinuation(request.origin, statusRescueActive, request.lastStop, request.turnError, msgsBefore) | |
| 241 | + | if request.force { | |
| 242 | + | continuationReason = compactContinuationForcedLength | |
| 243 | + | } | |
| 244 | + | // Keep the usual recent tail when possible, but never let it cover | |
| 245 | + | // the whole transcript. A short session can still be at 70–90% of | |
| 246 | + | // a model's window because one prompt or tool result is large; the | |
| 247 | + | // automatic path must summarize that session instead of failing | |
| 248 | + | // before it reaches the compaction provider. | |
| 249 | + | keepTail := 4 | |
| 250 | + | if n := len(msgsBefore); n > 0 && keepTail >= n { | |
| 251 | + | keepTail = n - 1 | |
| 252 | + | } | |
| 253 | + | summary, err := i.agent.Compact(ctx, keepTail, sink) | |
| 254 | + | _ = summary | |
| 255 | + | goalMessage, goalActive := i.goalContinuationMessage() | |
| 256 | + | i.mu.Lock() | |
| 257 | + | // Keep busy/compacting asserted while cleanup and queue selection run. | |
| 258 | + | // Completion updates can arrive in this window; clearing busy before | |
| 259 | + | // inspecting the queues lets them race a new turn or be stranded. | |
| 260 | + | i.resetStreamingStateLocked() | |
| 261 | + | i.cancelTurn = nil | |
| 262 | + | i.autoCompacting = false | |
| 263 | + | pendingIdleWork := i.takePendingIdleWorkLocked() | |
| 264 | + | ||
| 265 | + | // Drain pending work after the transcript is clean. Overflow recovery | |
| 266 | + | // continues the user message already in the transcript. Pre-turn | |
| 267 | + | // compaction preserves its complete text-and-images request. Regular | |
| 268 | + | // prompts typed during compaction remain in the host queue. | |
| 269 | + | var next string | |
| 270 | + | var nextImages []provider.ImageBlock | |
| 271 | + | var hasNext bool | |
| 272 | + | var continueExisting bool | |
| 273 | + | var continueAutomatically bool | |
| 274 | + | var continueGoal bool | |
| 275 | + | var handoff json.RawMessage | |
| 276 | + | var persistHandoff bool | |
| 277 | + | ||
| 278 | + | switch { | |
| 279 | + | case err != nil && ctx.Err() != nil: | |
| 280 | + | i.statusErr = "" | |
| 281 | + | if auto { | |
| 282 | + | i.statusOK = "auto-condense cancelled" | |
| 283 | + | } else { | |
| 284 | + | i.statusOK = "compaction cancelled" | |
| 285 | + | } | |
| 286 | + | i.queued = nil // drop queue on cancel | |
| 287 | + | i.clearPendingCompactTurnLocked() | |
| 288 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 289 | + | if i.agent != nil { | |
| 290 | + | i.agent.DrainQueuedMessages() | |
| 291 | + | } | |
| 292 | + | case err != nil: | |
| 293 | + | i.statusErr = "compaction failed: " + err.Error() | |
| 294 | + | i.statusOK = "" | |
| 295 | + | i.queued = nil // drop queue on error | |
| 296 | + | i.clearPendingCompactTurnLocked() | |
| 297 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 298 | + | if i.agent != nil { | |
| 299 | + | i.agent.DrainQueuedMessages() | |
| 300 | + | } | |
| 301 | + | default: | |
| 302 | + | i.statusErr = "" | |
| 303 | + | // Read token count from the compaction message meta. | |
| 304 | + | tokens := "" | |
| 305 | + | msgs := i.agent.Messages() | |
| 306 | + | if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" { | |
| 307 | + | tokens = msgs[0].Meta["tokens_before"] | |
| 308 | + | } | |
| 309 | + | switch { | |
| 310 | + | case i.pendingPostCompactNote != "": | |
| 311 | + | i.statusOK = i.pendingPostCompactNote | |
| 312 | + | case tokens != "": | |
| 313 | + | i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens) | |
| 314 | + | default: | |
| 315 | + | i.statusOK = "compacted (ctrl+o to expand)" | |
| 316 | + | } | |
| 317 | + | i.pendingPostCompactNote = "" | |
| 318 | + | i.extNotes = stripAutoCompactNotes(i.extNotes) | |
| 319 | + | i.lastCtxInput = 0 | |
| 320 | + | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 321 | + | i.toolOrder = nil | |
| 322 | + | i.toolGate = map[string]int{} | |
| 323 | + | i.resetTranscriptRenderLocked() | |
| 324 | + | switch { | |
| 325 | + | case i.continueAfterCompact: | |
| 326 | + | continueExisting = true | |
| 327 | + | i.continueAfterCompact = false | |
| 328 | + | case i.hasPendingCompactPrompt: | |
| 329 | + | next = i.pendingCompactPrompt | |
| 330 | + | nextImages = i.pendingCompactImages | |
| 331 | + | i.pendingCompactPrompt = "" | |
| 332 | + | i.pendingCompactImages = nil | |
| 333 | + | i.hasPendingCompactPrompt = false | |
| 334 | + | hasNext = true | |
| 335 | + | case continuationReason == compactContinuationForcedLength: | |
| 336 | + | // Forced truncated-output continuation keeps its existing | |
| 337 | + | // priority over an explicitly queued prompt. | |
| 338 | + | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason}) | |
| 339 | + | continueAutomatically = true | |
| 340 | + | case len(i.queued) > 0: | |
| 341 | + | next, i.queued = i.queued[0], i.queued[1:] | |
| 342 | + | hasNext = true | |
| 343 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 344 | + | case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue: | |
| 345 | + | if continuationReason == compactContinuationStatusRescue { | |
| 346 | + | attempts := 1 | |
| 347 | + | if i.compactContinuation.reason == compactContinuationStatusRescue { | |
| 348 | + | attempts = i.compactContinuation.rescueAttempts + 1 | |
| 349 | + | } | |
| 350 | + | if attempts > maxStatusRescueContinuations { | |
| 351 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 352 | + | break | |
| 353 | + | } | |
| 354 | + | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{ | |
| 355 | + | reason: continuationReason, | |
| 356 | + | rescueAttempts: attempts, | |
| 357 | + | }) | |
| 358 | + | } else { | |
| 359 | + | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason}) | |
| 360 | + | } | |
| 361 | + | continueAutomatically = true | |
| 362 | + | case auto && goalActive && !i.coordinatorHasPendingWorkers(): | |
| 363 | + | continueGoal = true | |
| 364 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 365 | + | default: | |
| 366 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | // Keep the host busy until the hand-off decision is committed under | |
| 370 | + | // the mutex. A completion update arriving after the compaction result | |
| 371 | + | // but before this assignment is then queued for the selected follow-up | |
| 372 | + | // instead of starting a competing turn. | |
| 373 | + | i.busy = hasNext || continueExisting || continueAutomatically || continueGoal | |
| 374 | + | i.compacting = false | |
| 375 | + | i.autoCompacting = false | |
| 376 | + | i.mu.Unlock() | |
| 377 | + | if persistHandoff { | |
| 378 | + | i.persistCompactHandoff(handoff) | |
| 379 | + | } | |
| 380 | + | runPendingIdleWork(pendingIdleWork) | |
| 381 | + | i.invalidate() | |
| 382 | + | ||
| 383 | + | if hasNext || continueExisting || continueAutomatically || continueGoal { | |
| 384 | + | p := i.runCtx | |
| 385 | + | if p == nil { | |
| 386 | + | p = context.Background() | |
| 387 | + | } | |
| 388 | + | switch { | |
| 389 | + | case continueExisting: | |
| 390 | + | i.startTurnRequest(p, "", nil, true, true) | |
| 391 | + | case hasNext: | |
| 392 | + | i.startTurnWithImages(p, next, nextImages) | |
| 393 | + | case continueAutomatically: | |
| 394 | + | i.startAutoCompactContinuation(p) | |
| 395 | + | case continueGoal: | |
| 396 | + | i.startGoalContinuation(p, goalMessage) | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | }() | |
| 400 | + | } | |
A packages/agent/modes/interactive_events.go +347 -0
| @@ -0,0 +1,347 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zut/packages/agent/tools" | |
| 9 | + | "github.com/bnema/zut/packages/core" | |
| 10 | + | "github.com/bnema/zut/packages/provider" | |
| 11 | + | "github.com/bnema/zut/packages/tui" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func eventAffectsPresentation(ev core.AgentEvent) bool { | |
| 15 | + | switch ev.(type) { | |
| 16 | + | case core.EvToolProgress: | |
| 17 | + | return false | |
| 18 | + | default: | |
| 19 | + | return true | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | func (i *Interactive) bumpToolRevisionLocked(tc *tui.ToolCallView) { | |
| 23 | + | i.toolRenderRevision++ | |
| 24 | + | if i.toolRenderRevision == 0 { | |
| 25 | + | // Overflow is practically unreachable, but resetting the sequence must | |
| 26 | + | // also drop old revisions before they can alias new frames. | |
| 27 | + | if i.view != nil { | |
| 28 | + | i.view.InvalidateRenderCache() | |
| 29 | + | } | |
| 30 | + | i.toolRenderRevision = 1 | |
| 31 | + | } | |
| 32 | + | tc.Revision = i.toolRenderRevision | |
| 33 | + | } | |
| 34 | + | func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) { | |
| 35 | + | i.handleEvent(ev) | |
| 36 | + | // Progress payloads are still delivered to the core sink and applied | |
| 37 | + | // in order, but they have no interactive-visible representation. | |
| 38 | + | if eventAffectsPresentation(ev) { | |
| 39 | + | i.invalidate() | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | func (i *Interactive) handleEvent(ev core.AgentEvent) { | |
| 43 | + | i.mu.Lock() | |
| 44 | + | defer i.mu.Unlock() | |
| 45 | + | i.activity.apply(ev) | |
| 46 | + | switch e := ev.(type) { | |
| 47 | + | case core.EvAssistantStart: | |
| 48 | + | // Fires at the top of every oneTurn, including follow-up | |
| 49 | + | // turns after tool use. Without this, the streaming buffer | |
| 50 | + | // is still marked off from the previous assistant message | |
| 51 | + | // and the final summary text pops in all at once instead | |
| 52 | + | // of typewriter-streaming delta by delta. | |
| 53 | + | i.streaming.Reset() | |
| 54 | + | i.streamPending = i.streamPending[:0] | |
| 55 | + | i.streamFlushPending = false | |
| 56 | + | i.streamOn = true | |
| 57 | + | // Clear the live tool-call overlay. Any tools from the | |
| 58 | + | // previous round are now fully folded into the transcript | |
| 59 | + | // (assistant tool_use block + tool role message with the | |
| 60 | + | // result), so keeping them in the overlay would duplicate | |
| 61 | + | // them in the view — once inside the finalised transcript | |
| 62 | + | // and once below the streaming block, with the streaming | |
| 63 | + | // summary sandwiched in between. The next EvToolUseStart | |
| 64 | + | // will populate fresh entries for this turn's tools. | |
| 65 | + | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 66 | + | i.toolOrder = nil | |
| 67 | + | i.toolGate = map[string]int{} | |
| 68 | + | case core.EvTextDelta: | |
| 69 | + | // Buffer into streamPending; the paintPace ticker drains | |
| 70 | + | // it into i.streaming a few runes at a time for a smooth | |
| 71 | + | // typewriter effect independent of upstream chunk size. | |
| 72 | + | i.streamPending = append(i.streamPending, []rune(e.Delta)...) | |
| 73 | + | i.streamOn = true | |
| 74 | + | case core.EvAssistantMessage: | |
| 75 | + | // OnAssistant + telegram mirroring always fire on message | |
| 76 | + | // arrival — they read the FINAL message content, which is | |
| 77 | + | // complete regardless of what's still in the pacer. | |
| 78 | + | i.assistantMessageSideEffects(e.Message) | |
| 79 | + | // If the pacer still has characters to drain, keep streamOn | |
| 80 | + | // true and mark flush pending; the paintPace ticker will | |
| 81 | + | // drain the remainder and reset streaming state when done. | |
| 82 | + | // Otherwise (rare: full-replay sessions, abort paths) clear | |
| 83 | + | // synchronously so a later render doesn't show stale text. | |
| 84 | + | if len(i.streamPending) > 0 { | |
| 85 | + | i.streamFlushPending = true | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | i.resetStreamingStateLocked() | |
| 89 | + | case core.EvToolUseStart: | |
| 90 | + | // Live streaming: pre-create the view so the user sees the | |
| 91 | + | // tool call being composed in real time. Any subsequent | |
| 92 | + | // EvToolCall for the same ID updates the same struct (the | |
| 93 | + | // final parsed args + name are already known here). | |
| 94 | + | if _, exists := i.toolCalls[e.ID]; !exists { | |
| 95 | + | tc := &tui.ToolCallView{ | |
| 96 | + | ID: e.ID, | |
| 97 | + | Name: e.Name, | |
| 98 | + | Streaming: true, | |
| 99 | + | } | |
| 100 | + | i.bumpToolRevisionLocked(tc) | |
| 101 | + | i.toolCalls[e.ID] = tc | |
| 102 | + | i.toolOrder = append(i.toolOrder, e.ID) | |
| 103 | + | i.gateToolLocked(e.ID) | |
| 104 | + | } | |
| 105 | + | case core.EvToolUseArgs: | |
| 106 | + | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 107 | + | tc.RawJSONBuf += e.Delta | |
| 108 | + | i.bumpToolRevisionLocked(tc) | |
| 109 | + | // Refresh the live path as soon as it parses; used in | |
| 110 | + | // the header (write /Users/example/Desktop/demo.ts) | |
| 111 | + | // while the content is still streaming. | |
| 112 | + | if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok { | |
| 113 | + | tc.LivePath = p | |
| 114 | + | } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok { | |
| 115 | + | tc.LivePath = p | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | case core.EvToolUseEnd: | |
| 119 | + | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 120 | + | tc.Streaming = false | |
| 121 | + | i.bumpToolRevisionLocked(tc) | |
| 122 | + | } | |
| 123 | + | case core.EvToolCall: | |
| 124 | + | // If we already pre-created the view during streaming, just | |
| 125 | + | // refresh the final Args summary. Otherwise create a new one | |
| 126 | + | // (non-streaming providers or legacy paths). | |
| 127 | + | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 128 | + | tc.Args = tui.ShortArgs(e.Name, e.Args) | |
| 129 | + | i.bumpToolRevisionLocked(tc) | |
| 130 | + | if tc.RawJSONBuf == "" { | |
| 131 | + | tc.RawJSONBuf = string(e.Args) | |
| 132 | + | } | |
| 133 | + | tc.Streaming = false | |
| 134 | + | } else { | |
| 135 | + | tc := &tui.ToolCallView{ | |
| 136 | + | ID: e.ID, | |
| 137 | + | Name: e.Name, | |
| 138 | + | Args: tui.ShortArgs(e.Name, e.Args), | |
| 139 | + | RawJSONBuf: string(e.Args), | |
| 140 | + | } | |
| 141 | + | i.bumpToolRevisionLocked(tc) | |
| 142 | + | i.toolCalls[e.ID] = tc | |
| 143 | + | i.toolOrder = append(i.toolOrder, e.ID) | |
| 144 | + | i.gateToolLocked(e.ID) | |
| 145 | + | } | |
| 146 | + | case core.EvToolResult: | |
| 147 | + | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 148 | + | tc.Done = true | |
| 149 | + | tc.Error = e.Result.IsError | |
| 150 | + | tc.Preview = "" | |
| 151 | + | var text strings.Builder | |
| 152 | + | for _, c := range e.Result.Content { | |
| 153 | + | if tb, ok := c.(provider.TextBlock); ok { | |
| 154 | + | if text.Len() > 0 { | |
| 155 | + | text.WriteString("\n") | |
| 156 | + | } | |
| 157 | + | text.WriteString(tb.Text) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | tc.Result = text.String() | |
| 161 | + | i.bumpToolRevisionLocked(tc) | |
| 162 | + | } | |
| 163 | + | if update, ok := tools.GoalUpdateFromResult(e.Result); ok { | |
| 164 | + | if update.Status == core.GoalActive { | |
| 165 | + | // A manager may advance a terminal goal to the next persisted | |
| 166 | + | // goal in the same mission. Do not let a late tool result resume | |
| 167 | + | // a goal explicitly paused by the user. | |
| 168 | + | if i.goalStatus != core.GoalPaused { | |
| 169 | + | i.goalStatus = core.GoalActive | |
| 170 | + | } | |
| 171 | + | } else if i.goalStatus == core.GoalActive { | |
| 172 | + | i.goalStatus = update.Status | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | case core.EvUsage: | |
| 176 | + | i.cumUsage = e.Cumulative | |
| 177 | + | if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 { | |
| 178 | + | i.lastCtxInput = contextUsed | |
| 179 | + | } | |
| 180 | + | case core.EvTurnEnd: | |
| 181 | + | if e.Stop == provider.StopAborted { | |
| 182 | + | i.resetStreamingStateLocked() | |
| 183 | + | i.statusErr = "" | |
| 184 | + | i.statusOK = "cancelled" | |
| 185 | + | return | |
| 186 | + | } | |
| 187 | + | if e.Stop == provider.StopLength { | |
| 188 | + | // The model hit its output-token cap mid-response, so the | |
| 189 | + | // reply (often a long write/edit) is truncated. Surface it | |
| 190 | + | // explicitly, otherwise the turn just ends and reads like | |
| 191 | + | // the UI gave up. The agent already requests the model's | |
| 192 | + | // full MaxOutput budget, so this means the response genuinely | |
| 193 | + | // exceeded that ceiling; ask the user to continue. | |
| 194 | + | i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue" | |
| 195 | + | i.statusOK = "" | |
| 196 | + | return | |
| 197 | + | } | |
| 198 | + | // Don't surface mid-loop stream errors as a red banner here. | |
| 199 | + | // EvTurnEnd fires after every step in a multi-step tool loop, | |
| 200 | + | // so a transient 503 / network blip would briefly paint a red | |
| 201 | + | // banner over the still-streaming chat before the agent loop | |
| 202 | + | // either retries or exits. The final error (if any) is set by | |
| 203 | + | // startTurnWithImages once Prompt() returns, and recoverable | |
| 204 | + | // failures are routed to the rescue picker instead — which | |
| 205 | + | // keeps the chat clean while the agent is working. | |
| 206 | + | _ = e.Err | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | func (i *Interactive) WebSearchPolicyGeneration() uint64 { | |
| 210 | + | return i.webSearchPolicyGeneration.Load() | |
| 211 | + | } | |
| 212 | + | func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) { | |
| 213 | + | return i.applyAgentPromptConfig(ag, system, tools, 0, false) | |
| 214 | + | } | |
| 215 | + | func (i *Interactive) ApplyAgentPromptConfigAtWebSearchGeneration(ag *core.Agent, system string, tools core.Registry, generation uint64) (core.Registry, bool) { | |
| 216 | + | return i.applyAgentPromptConfig(ag, system, tools, generation, true) | |
| 217 | + | } | |
| 218 | + | func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry, generation uint64, checkGeneration bool) (core.Registry, bool) { | |
| 219 | + | if ag == nil { | |
| 220 | + | return nil, false | |
| 221 | + | } | |
| 222 | + | // Match the lock order used by dynamic tool mutations so a refresh | |
| 223 | + | // cannot race a snapshot-and-replace operation that would reintroduce | |
| 224 | + | // the old registry after the atomic prompt swap. | |
| 225 | + | i.agentMu.Lock() | |
| 226 | + | defer i.agentMu.Unlock() | |
| 227 | + | i.mu.Lock() | |
| 228 | + | defer i.mu.Unlock() | |
| 229 | + | if i.agent != ag || checkGeneration && i.webSearchPolicyGeneration.Load() != generation { | |
| 230 | + | return nil, false | |
| 231 | + | } | |
| 232 | + | if i.telegramBridge != nil { | |
| 233 | + | // Telegram prompts arrive over an external messaging channel without a | |
| 234 | + | // per-request confirmation surface. Keep the normal interactive | |
| 235 | + | // registry from reintroducing web_search from the moment the bridge is | |
| 236 | + | // attached, including while its startup handshake is in flight. | |
| 237 | + | delete(tools, "web_search") | |
| 238 | + | } | |
| 239 | + | oldTools := ag.SetPromptConfig(system, tools) | |
| 240 | + | _, webSearchAvailable := tools["web_search"] | |
| 241 | + | i.setWebSearchAvailable(webSearchAvailable) | |
| 242 | + | return oldTools, true | |
| 243 | + | } | |
| 244 | + | func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) { | |
| 245 | + | if ag == nil { | |
| 246 | + | i.setWebSearchAvailable(false) | |
| 247 | + | return | |
| 248 | + | } | |
| 249 | + | registry := ag.ToolsSnapshot() | |
| 250 | + | if i.telegramBridge != nil { | |
| 251 | + | // External Telegram prompts may arrive as soon as the replacement is | |
| 252 | + | // published, so post-swap cleanup is too late. | |
| 253 | + | delete(registry, "web_search") | |
| 254 | + | ag.SetTools(registry) | |
| 255 | + | } | |
| 256 | + | _, webSearchAvailable := registry["web_search"] | |
| 257 | + | i.setWebSearchAvailable(webSearchAvailable && i.telegramBridge == nil) | |
| 258 | + | } | |
| 259 | + | func (i *Interactive) DeferUntilIdle(fn func()) { | |
| 260 | + | if fn == nil { | |
| 261 | + | return | |
| 262 | + | } | |
| 263 | + | i.mu.Lock() | |
| 264 | + | if i.busy { | |
| 265 | + | i.pendingIdleWork = append(i.pendingIdleWork, fn) | |
| 266 | + | i.mu.Unlock() | |
| 267 | + | return | |
| 268 | + | } | |
| 269 | + | i.mu.Unlock() | |
| 270 | + | fn() | |
| 271 | + | } | |
| 272 | + | func (i *Interactive) takePendingIdleWorkLocked() []func() { | |
| 273 | + | work := i.pendingIdleWork | |
| 274 | + | i.pendingIdleWork = nil | |
| 275 | + | return work | |
| 276 | + | } | |
| 277 | + | func runPendingIdleWork(work []func()) { | |
| 278 | + | for _, fn := range work { | |
| 279 | + | if fn != nil { | |
| 280 | + | fn() | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | func (i *Interactive) Agent() *core.Agent { | |
| 285 | + | i.mu.Lock() | |
| 286 | + | defer i.mu.Unlock() | |
| 287 | + | return i.agent | |
| 288 | + | } | |
| 289 | + | func (i *Interactive) runReloadExt(ctx context.Context) { | |
| 290 | + | if i.cfg.Extensions == nil { | |
| 291 | + | i.mu.Lock() | |
| 292 | + | i.statusErr = "no extension manager in this build" | |
| 293 | + | i.mu.Unlock() | |
| 294 | + | i.invalidate() | |
| 295 | + | return | |
| 296 | + | } | |
| 297 | + | i.mu.Lock() | |
| 298 | + | i.statusOK = "Reloading extensions..." | |
| 299 | + | i.statusErr = "" | |
| 300 | + | i.mu.Unlock() | |
| 301 | + | i.invalidate() | |
| 302 | + | ||
| 303 | + | go func() { | |
| 304 | + | stats := i.cfg.Extensions.Reload(ctx, 2*time.Second) | |
| 305 | + | msg, failed := formatReloadStatus(stats) | |
| 306 | + | seq := i.setReloadStatus(msg, failed) | |
| 307 | + | i.invalidate() | |
| 308 | + | go i.dismissReloadStatus(ctx, seq, msg, failed) | |
| 309 | + | }() | |
| 310 | + | } | |
| 311 | + | func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision { | |
| 312 | + | return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview}) | |
| 313 | + | } | |
| 314 | + | func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision { | |
| 315 | + | resp := make(chan core.ConfirmDecision, 1) | |
| 316 | + | if isBtwOrigin(call.Origin) { | |
| 317 | + | req := &confirmRequest{ | |
| 318 | + | toolName: call.Name, | |
| 319 | + | preview: call.Summary, | |
| 320 | + | resp: resp, | |
| 321 | + | returnToChild: true, | |
| 322 | + | } | |
| 323 | + | if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() { | |
| 324 | + | i.confirmDialog.Enqueue(req) | |
| 325 | + | }) { | |
| 326 | + | return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"} | |
| 327 | + | } | |
| 328 | + | i.invalidate() | |
| 329 | + | return <-resp | |
| 330 | + | } | |
| 331 | + | if call.ID != "" { | |
| 332 | + | i.mu.Lock() | |
| 333 | + | if tc, ok := i.toolCalls[call.ID]; ok { | |
| 334 | + | tc.Args = call.Summary | |
| 335 | + | tc.Preview = call.Content | |
| 336 | + | i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model} | |
| 337 | + | } | |
| 338 | + | i.mu.Unlock() | |
| 339 | + | } | |
| 340 | + | i.confirmDialog.Enqueue(&confirmRequest{ | |
| 341 | + | toolName: call.Name, | |
| 342 | + | preview: call.Summary, | |
| 343 | + | resp: resp, | |
| 344 | + | }) | |
| 345 | + | i.invalidate() | |
| 346 | + | return <-resp | |
| 347 | + | } | |
A packages/agent/modes/interactive_host.go +417 -0
| @@ -0,0 +1,417 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "strings" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/zut/packages/agent/extproto" | |
| 12 | + | "github.com/bnema/zut/packages/agent/internal/orchestration" | |
| 13 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 14 | + | "github.com/bnema/zut/packages/core" | |
| 15 | + | "github.com/bnema/zut/packages/provider" | |
| 16 | + | "github.com/bnema/zut/packages/tui" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) { | |
| 20 | + | resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second) | |
| 21 | + | if err != nil { | |
| 22 | + | i.mu.Lock() | |
| 23 | + | i.statusErr = "extension /" + name + ": " + err.Error() | |
| 24 | + | i.statusOK = "" | |
| 25 | + | i.mu.Unlock() | |
| 26 | + | i.invalidate() | |
| 27 | + | return | |
| 28 | + | } | |
| 29 | + | if resp.Error != "" { | |
| 30 | + | i.mu.Lock() | |
| 31 | + | i.statusErr = "extension /" + name + ": " + resp.Error | |
| 32 | + | i.statusOK = "" | |
| 33 | + | i.mu.Unlock() | |
| 34 | + | i.invalidate() | |
| 35 | + | return | |
| 36 | + | } | |
| 37 | + | switch resp.Action { | |
| 38 | + | case "open_panel": | |
| 39 | + | if resp.OpenPanel != nil { | |
| 40 | + | extName := name | |
| 41 | + | if i.cfg.Extensions != nil { | |
| 42 | + | if owner := i.cfg.Extensions.CommandOwner(name); owner != "" { | |
| 43 | + | extName = owner | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | i.OpenPanel(extName, *resp.OpenPanel) | |
| 47 | + | } | |
| 48 | + | case "prompt": | |
| 49 | + | if strings.TrimSpace(resp.Prompt) == "" { | |
| 50 | + | return | |
| 51 | + | } | |
| 52 | + | i.startTurn(i.runCtx, resp.Prompt) | |
| 53 | + | case "insert": | |
| 54 | + | i.ed.Insert(resp.Insert) | |
| 55 | + | i.invalidate() | |
| 56 | + | case "display": | |
| 57 | + | i.appendExtensionNote(name, resp.Display, "info") | |
| 58 | + | case "noop", "": | |
| 59 | + | // nothing | |
| 60 | + | default: | |
| 61 | + | i.mu.Lock() | |
| 62 | + | i.statusErr = "extension /" + name + ": unknown action " + resp.Action | |
| 63 | + | i.mu.Unlock() | |
| 64 | + | i.invalidate() | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | func (i *Interactive) appendExtensionNote(extName, msg, level string) { | |
| 68 | + | if msg == "" { | |
| 69 | + | return | |
| 70 | + | } | |
| 71 | + | i.mu.Lock() | |
| 72 | + | defer i.mu.Unlock() | |
| 73 | + | color := i.cfg.Theme.Muted | |
| 74 | + | switch level { | |
| 75 | + | case "warn": | |
| 76 | + | color = i.cfg.Theme.Warning | |
| 77 | + | case "error": | |
| 78 | + | color = i.cfg.Theme.Error | |
| 79 | + | case "success": | |
| 80 | + | color = i.cfg.Theme.Tool | |
| 81 | + | } | |
| 82 | + | prefix := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, "["+extName+"] ") | |
| 83 | + | for _, line := range strings.Split(msg, "\n") { | |
| 84 | + | i.statusOK = "" // clear any stale ok | |
| 85 | + | i.statusErr = "" | |
| 86 | + | i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FGColor(color, line)) | |
| 87 | + | } | |
| 88 | + | // Extension slash commands complete asynchronously. If their result is | |
| 89 | + | // displayed while confirmation is waiting, keep Esc assigned to the note | |
| 90 | + | // until it is dismissed rather than letting it answer the tool call. | |
| 91 | + | i.confirmDialog.Blur() | |
| 92 | + | } | |
| 93 | + | func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model string) { | |
| 94 | + | i.ApplySessionAgentWithCompactHandoff(ag, providerName, model, nil) | |
| 95 | + | } | |
| 96 | + | func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, providerName, model string, compactHandoff json.RawMessage) { | |
| 97 | + | if ag == nil { | |
| 98 | + | return | |
| 99 | + | } | |
| 100 | + | i.agentMu.Lock() | |
| 101 | + | defer i.agentMu.Unlock() | |
| 102 | + | i.mu.Lock() | |
| 103 | + | i.prepareReplacementAgentLocked(ag) | |
| 104 | + | i.compactContinuation = decodeCompactHandoff(compactHandoff) | |
| 105 | + | i.agent = ag | |
| 106 | + | i.cfg.Provider = providerName | |
| 107 | + | i.cfg.Model = model | |
| 108 | + | i.view.Messages = filterHiddenTranscriptMessages(ag.Messages()) | |
| 109 | + | i.cumUsage = ag.Cost() | |
| 110 | + | last := ag.LastTurnUsage() | |
| 111 | + | i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens | |
| 112 | + | if len(i.view.Messages) > initialResumeTailLimit { | |
| 113 | + | i.view.TailLimit = initialResumeTailLimit | |
| 114 | + | } else { | |
| 115 | + | i.view.TailLimit = 0 | |
| 116 | + | } | |
| 117 | + | i.view.InvalidateRenderCache() | |
| 118 | + | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 119 | + | i.toolOrder = nil | |
| 120 | + | i.toolGate = map[string]int{} | |
| 121 | + | i.helpBlock = nil | |
| 122 | + | i.extNotes = nil | |
| 123 | + | i.parkedTurn = 0 | |
| 124 | + | i.parkedTotal = 0 | |
| 125 | + | i.mu.Unlock() | |
| 126 | + | i.invalidate() | |
| 127 | + | } | |
| 128 | + | func (i *Interactive) SetSubagentSessionScope(_ string) { | |
| 129 | + | i.invalidate() | |
| 130 | + | } | |
| 131 | + | func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) { | |
| 132 | + | i.applyChangedCWD(ag, provider, model, cwd, nil) | |
| 133 | + | } | |
| 134 | + | func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) { | |
| 135 | + | i.applyChangedCWD(ag, provider, model, cwd, startupContextPaths) | |
| 136 | + | } | |
| 137 | + | func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) { | |
| 138 | + | home, _ := os.UserHomeDir() | |
| 139 | + | profiles, _ := subagents.Discover(cwd, home) | |
| 140 | + | subagentsAddendum := subagents.SystemPromptAddendum(profiles) | |
| 141 | + | ||
| 142 | + | i.agentMu.Lock() | |
| 143 | + | defer i.agentMu.Unlock() | |
| 144 | + | i.mu.Lock() | |
| 145 | + | i.prepareReplacementAgentLocked(ag) | |
| 146 | + | i.resetCompactContinuationLocked() | |
| 147 | + | i.agent = ag | |
| 148 | + | i.cfg.CWD = cwd | |
| 149 | + | i.cfg.SubagentsSystemAddendum = subagentsAddendum | |
| 150 | + | i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked()) | |
| 151 | + | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | |
| 152 | + | i.view.StartupContextPaths = nil | |
| 153 | + | i.view.InvalidateRenderCache() | |
| 154 | + | if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup { | |
| 155 | + | i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...) | |
| 156 | + | } | |
| 157 | + | // Re-report the working directory to the terminal so "new tab here" | |
| 158 | + | // tracks the /cd change (OSC 7, see issue #38). | |
| 159 | + | if i.cfg.Terminal != nil { | |
| 160 | + | if seq := tui.ReportCWD(cwd); seq != "" { | |
| 161 | + | _, _ = i.cfg.Terminal.Write([]byte(seq)) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | i.cfg.Provider = provider | |
| 165 | + | i.cfg.Model = model | |
| 166 | + | titleCancel := i.titleCancel | |
| 167 | + | i.titleCancel = nil | |
| 168 | + | i.titleVersion++ | |
| 169 | + | i.sessionTitle = "" | |
| 170 | + | i.titleRealPromptSeen = false | |
| 171 | + | i.titleGenerationStarted = false | |
| 172 | + | i.writeTerminalTitleLocked("") | |
| 173 | + | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 174 | + | i.toolOrder = nil | |
| 175 | + | i.toolGate = map[string]int{} | |
| 176 | + | i.helpBlock = nil | |
| 177 | + | i.parkedTurn = 0 | |
| 178 | + | i.statusErr = "" | |
| 179 | + | i.mu.Unlock() | |
| 180 | + | if titleCancel != nil { | |
| 181 | + | titleCancel() | |
| 182 | + | } | |
| 183 | + | i.fileSuggest.Reset() | |
| 184 | + | i.fileSuggest.SetCWD(cwd) | |
| 185 | + | i.invalidate() | |
| 186 | + | } | |
| 187 | + | func (i *Interactive) SubmitSlash(text string) { | |
| 188 | + | i.mu.Lock() | |
| 189 | + | i.doubleEscape.Reset() | |
| 190 | + | i.mu.Unlock() | |
| 191 | + | text = strings.TrimSpace(text) | |
| 192 | + | if !strings.HasPrefix(text, "/") { | |
| 193 | + | return | |
| 194 | + | } | |
| 195 | + | if slashCommandCancelsTurn(text) { | |
| 196 | + | i.cancelAndWaitForIdle() | |
| 197 | + | } | |
| 198 | + | i.runSlash(i.runCtx, text) | |
| 199 | + | i.invalidate() | |
| 200 | + | } | |
| 201 | + | func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) { | |
| 202 | + | i.submitOrQueue(text, images, true) | |
| 203 | + | } | |
| 204 | + | func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, userInput bool) { | |
| 205 | + | if cmd, ok := shellEscapeCommand(text); ok { | |
| 206 | + | i.startShellEscape(i.runCtx, cmd) | |
| 207 | + | return | |
| 208 | + | } | |
| 209 | + | i.mu.Lock() | |
| 210 | + | if i.agent == nil { | |
| 211 | + | i.statusErr = "not logged in. type /login first." | |
| 212 | + | i.mu.Unlock() | |
| 213 | + | i.invalidate() | |
| 214 | + | return | |
| 215 | + | } | |
| 216 | + | i.mu.Unlock() | |
| 217 | + | if userInput && i.cfg.EnsureMission != nil { | |
| 218 | + | if err := i.cfg.EnsureMission(text); err != nil { | |
| 219 | + | i.ReportError(fmt.Errorf("persist mission: %w", err)) | |
| 220 | + | return | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | // User input is an orchestration event, not merely a queue append. When | |
| 224 | + | // the manager is dormant behind a sealed worker wave, the coordinator makes | |
| 225 | + | // this the next manager turn and carries any completed worker summary with it. | |
| 226 | + | if userInput && i.coordinatorAcceptsUserInput() { | |
| 227 | + | if actions := i.applyCoordinator(orchestration.Event{Kind: orchestration.EventUserInput, Text: text}); len(actions) != 0 { | |
| 228 | + | i.executeCoordinatorActions(actions) | |
| 229 | + | return | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | i.maybeStartSessionTitle(i.runCtx, text) | |
| 233 | + | i.mu.Lock() | |
| 234 | + | if i.busy { | |
| 235 | + | // Queue text only; images are dropped for queued prompts. Keep the | |
| 236 | + | // interactive mutex held through Agent.QueueMessage so turn teardown | |
| 237 | + | // cannot inspect the agent queue between the busy check and enqueue. | |
| 238 | + | // Compaction uses the host queue because it has no active agent loop | |
| 239 | + | // to drain Agent.QueueMessage entries. | |
| 240 | + | var handoff json.RawMessage | |
| 241 | + | var persistHandoff bool | |
| 242 | + | if i.agent != nil && !i.compacting { | |
| 243 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 244 | + | i.agent.QueueMessage(text) | |
| 245 | + | } else { | |
| 246 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 247 | + | i.queued = append(i.queued, text) | |
| 248 | + | } | |
| 249 | + | i.mu.Unlock() | |
| 250 | + | if persistHandoff { | |
| 251 | + | i.persistCompactHandoff(handoff) | |
| 252 | + | } | |
| 253 | + | i.invalidate() | |
| 254 | + | return | |
| 255 | + | } | |
| 256 | + | i.mu.Unlock() | |
| 257 | + | i.startTurnWithImages(i.runCtx, text, images) | |
| 258 | + | } | |
| 259 | + | func (i *Interactive) ChangelogVersion() string { | |
| 260 | + | if i.changelogDialog != nil { | |
| 261 | + | return i.changelogDialog.version | |
| 262 | + | } | |
| 263 | + | return "" | |
| 264 | + | } | |
| 265 | + | func (i *Interactive) CancelTurn() { | |
| 266 | + | i.mu.Lock() | |
| 267 | + | cancel := i.cancelTurn | |
| 268 | + | i.mu.Unlock() | |
| 269 | + | if cancel != nil { | |
| 270 | + | i.cancelCoordinator() | |
| 271 | + | ||
| 272 | + | i.mu.Lock() | |
| 273 | + | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 274 | + | i.mu.Unlock() | |
| 275 | + | cancel() | |
| 276 | + | i.confirmDialog.CancelAll("turn cancelled") | |
| 277 | + | if persistHandoff { | |
| 278 | + | i.persistCompactHandoff(handoff) | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | func (i *Interactive) Insert(text string) { | |
| 283 | + | i.ed.Insert(text) | |
| 284 | + | i.invalidate() | |
| 285 | + | } | |
| 286 | + | func (i *Interactive) Display(extName, text string) { | |
| 287 | + | i.appendExtensionNote(extName, text, "info") | |
| 288 | + | i.invalidate() | |
| 289 | + | } | |
| 290 | + | func (i *Interactive) ReportError(err error) { | |
| 291 | + | if err == nil { | |
| 292 | + | return | |
| 293 | + | } | |
| 294 | + | i.mu.Lock() | |
| 295 | + | i.statusOK = "" | |
| 296 | + | i.statusErr = err.Error() | |
| 297 | + | i.mu.Unlock() | |
| 298 | + | i.invalidate() | |
| 299 | + | } | |
| 300 | + | func (i *Interactive) SetStatus(extName, key, level, text string) { | |
| 301 | + | if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" { | |
| 302 | + | return | |
| 303 | + | } | |
| 304 | + | i.mu.Lock() | |
| 305 | + | if i.extStatuses == nil { | |
| 306 | + | i.extStatuses = map[string]map[string]extensionStatus{} | |
| 307 | + | } | |
| 308 | + | if strings.TrimSpace(text) == "" { | |
| 309 | + | if items := i.extStatuses[extName]; items != nil { | |
| 310 | + | delete(items, key) | |
| 311 | + | if len(items) == 0 { | |
| 312 | + | delete(i.extStatuses, extName) | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | } else { | |
| 316 | + | if i.extStatuses[extName] == nil { | |
| 317 | + | i.extStatuses[extName] = map[string]extensionStatus{} | |
| 318 | + | } | |
| 319 | + | i.extStatuses[extName][key] = extensionStatus{Level: level, Text: text} | |
| 320 | + | } | |
| 321 | + | i.mu.Unlock() | |
| 322 | + | i.invalidate() | |
| 323 | + | } | |
| 324 | + | func (i *Interactive) SetWidget(extName, id, position, title string, lines []string) { | |
| 325 | + | if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" { | |
| 326 | + | return | |
| 327 | + | } | |
| 328 | + | position = extproto.NormalizeWidgetPosition(position) | |
| 329 | + | i.mu.Lock() | |
| 330 | + | if i.extWidgets == nil { | |
| 331 | + | i.extWidgets = map[string]map[string]extensionWidget{} | |
| 332 | + | } | |
| 333 | + | if i.extWidgets[extName] == nil { | |
| 334 | + | i.extWidgets[extName] = map[string]extensionWidget{} | |
| 335 | + | } | |
| 336 | + | i.extWidgets[extName][id] = extensionWidget{ | |
| 337 | + | Position: position, | |
| 338 | + | Title: title, | |
| 339 | + | Lines: append([]string(nil), lines...), | |
| 340 | + | } | |
| 341 | + | i.mu.Unlock() | |
| 342 | + | i.invalidate() | |
| 343 | + | } | |
| 344 | + | func (i *Interactive) ClearWidget(extName, id string) { | |
| 345 | + | i.mu.Lock() | |
| 346 | + | if items := i.extWidgets[extName]; items != nil { | |
| 347 | + | delete(items, id) | |
| 348 | + | if len(items) == 0 { | |
| 349 | + | delete(i.extWidgets, extName) | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | i.mu.Unlock() | |
| 353 | + | i.invalidate() | |
| 354 | + | } | |
| 355 | + | func (i *Interactive) ClearExtensionChrome(extName string) { | |
| 356 | + | if strings.TrimSpace(extName) == "" { | |
| 357 | + | return | |
| 358 | + | } | |
| 359 | + | marker := "[" + extName + "] " | |
| 360 | + | i.mu.Lock() | |
| 361 | + | changed := false | |
| 362 | + | if _, ok := i.extStatuses[extName]; ok { | |
| 363 | + | delete(i.extStatuses, extName) | |
| 364 | + | changed = true | |
| 365 | + | } | |
| 366 | + | if _, ok := i.extWidgets[extName]; ok { | |
| 367 | + | delete(i.extWidgets, extName) | |
| 368 | + | changed = true | |
| 369 | + | } | |
| 370 | + | if len(i.extNotes) > 0 { | |
| 371 | + | kept := i.extNotes[:0:0] | |
| 372 | + | for _, line := range i.extNotes { | |
| 373 | + | if strings.Contains(line, marker) { | |
| 374 | + | changed = true | |
| 375 | + | continue | |
| 376 | + | } | |
| 377 | + | kept = append(kept, line) | |
| 378 | + | } | |
| 379 | + | i.extNotes = kept | |
| 380 | + | } | |
| 381 | + | if i.extPanel != nil && i.extPanel.Active() && i.extPanel.ext == extName { | |
| 382 | + | i.extPanel.Close() | |
| 383 | + | changed = true | |
| 384 | + | } | |
| 385 | + | i.mu.Unlock() | |
| 386 | + | if changed { | |
| 387 | + | i.invalidate() | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) { | |
| 391 | + | i.mu.Lock() | |
| 392 | + | defer i.mu.Unlock() | |
| 393 | + | i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer) | |
| 394 | + | i.confirmDialog.Blur() | |
| 395 | + | if i.cfg.Extensions != nil { | |
| 396 | + | cols, rows := i.cfg.Terminal.Size() | |
| 397 | + | _ = cols | |
| 398 | + | _ = rows | |
| 399 | + | } | |
| 400 | + | i.invalidate() | |
| 401 | + | } | |
| 402 | + | func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) { | |
| 403 | + | i.mu.Lock() | |
| 404 | + | defer i.mu.Unlock() | |
| 405 | + | if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID { | |
| 406 | + | i.extPanel.Update(title, lines, footer) | |
| 407 | + | i.invalidate() | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | func (i *Interactive) ClosePanel(extName, panelID string) { | |
| 411 | + | i.mu.Lock() | |
| 412 | + | defer i.mu.Unlock() | |
| 413 | + | if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID { | |
| 414 | + | i.extPanel.Close() | |
| 415 | + | i.invalidate() | |
| 416 | + | } | |
| 417 | + | } | |
A packages/agent/modes/interactive_input.go +59 -0
| @@ -0,0 +1,59 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zut/packages/tui" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // prepareInputKey applies main-editor key state that must happen before | |
| 11 | + | // routing the key to a dialog, suggestion, or editor. It returns false when | |
| 12 | + | // clipboard resolution failed and the key must not be delivered further. | |
| 13 | + | func (i *Interactive) prepareInputKey(k tui.Key) (tui.Key, bool) { | |
| 14 | + | // A bare Escape is eligible for the session-tree gesture only while the | |
| 15 | + | // main input owns the key. Any other key, modified Escape, or visible | |
| 16 | + | // child interaction resets the pending first tap before normal routing. | |
| 17 | + | if !isUnmodifiedEscape(k) || i.sessionTreeEscapeBlocked() { | |
| 18 | + | i.mu.Lock() | |
| 19 | + | i.doubleEscape.Reset() | |
| 20 | + | i.mu.Unlock() | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // Dialogs route keys before the main clipboard handler below. Resolve text | |
| 24 | + | // here when a child interaction owns input so every editor and filter sees | |
| 25 | + | // the same KeyPaste event as terminal-native bracketed paste. Main chat is | |
| 26 | + | // left image-first to preserve macOS clipboard image attachments. | |
| 27 | + | if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() { | |
| 28 | + | resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText) | |
| 29 | + | if err != nil { | |
| 30 | + | i.mu.Lock() | |
| 31 | + | i.statusErr = "clipboard paste failed: " + err.Error() | |
| 32 | + | i.statusOK = "" | |
| 33 | + | i.mu.Unlock() | |
| 34 | + | return tui.Key{}, false | |
| 35 | + | } | |
| 36 | + | if pastedText { | |
| 37 | + | k = resolved | |
| 38 | + | i.mu.Lock() | |
| 39 | + | i.statusErr = "" | |
| 40 | + | i.statusOK = "" | |
| 41 | + | i.mu.Unlock() | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so pressing | |
| 46 | + | // ctrl+c then typing then ctrl+c much later doesn't quit unexpectedly. The | |
| 47 | + | // hint message also goes stale; clear it. | |
| 48 | + | if k.Kind != tui.KeyCtrlC { | |
| 49 | + | i.mu.Lock() | |
| 50 | + | if !i.lastCtrlC.IsZero() { | |
| 51 | + | i.lastCtrlC = time.Time{} | |
| 52 | + | if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") { | |
| 53 | + | i.statusOK = "" | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | i.mu.Unlock() | |
| 57 | + | } | |
| 58 | + | return k, true | |
| 59 | + | } | |
A packages/agent/modes/interactive_input_routing.go +1054 -0
| @@ -0,0 +1,1054 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zut/packages/core" | |
| 11 | + | "github.com/bnema/zut/packages/provider" | |
| 12 | + | "github.com/bnema/zut/packages/tui" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func (i *Interactive) armCtrlCExit() { | |
| 16 | + | i.mu.Lock() | |
| 17 | + | i.lastCtrlC = time.Now() | |
| 18 | + | i.mu.Unlock() | |
| 19 | + | } | |
| 20 | + | func (i *Interactive) ctrlCExitArmed() bool { | |
| 21 | + | i.mu.Lock() | |
| 22 | + | t := i.lastCtrlC | |
| 23 | + | i.mu.Unlock() | |
| 24 | + | return !t.IsZero() && time.Since(t) <= ctrlCExitWindow | |
| 25 | + | } | |
| 26 | + | func (i *Interactive) clearFileSuggestQuery() { | |
| 27 | + | val := i.ed.Value() | |
| 28 | + | if idx := strings.LastIndex(val, "@"); idx >= 0 { | |
| 29 | + | i.ed.SetValue(val[:idx+1]) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | func (i *Interactive) setToolExpansion(expanded bool) { | |
| 33 | + | i.mu.Lock() | |
| 34 | + | i.view.ExpandAll = expanded | |
| 35 | + | i.requestRendererClear() | |
| 36 | + | i.mu.Unlock() | |
| 37 | + | i.invalidate() | |
| 38 | + | } | |
| 39 | + | func (i *Interactive) toggleToolExpansion() { | |
| 40 | + | i.mu.Lock() | |
| 41 | + | expanded := !i.view.ExpandAll | |
| 42 | + | i.mu.Unlock() | |
| 43 | + | i.setToolExpansion(expanded) | |
| 44 | + | } | |
| 45 | + | func (i *Interactive) toggleBtwToolExpansion() { | |
| 46 | + | i.btwDialog.ToggleToolExpansion() | |
| 47 | + | i.mu.Lock() | |
| 48 | + | i.requestRendererClear() | |
| 49 | + | i.mu.Unlock() | |
| 50 | + | i.invalidate() | |
| 51 | + | } | |
| 52 | + | func (i *Interactive) confirmChildActive() bool { | |
| 53 | + | i.mu.Lock() | |
| 54 | + | defer i.mu.Unlock() | |
| 55 | + | return i.confirmChildActiveLocked() | |
| 56 | + | } | |
| 57 | + | func (i *Interactive) confirmChildActiveLocked() bool { | |
| 58 | + | return len(i.helpBlock) > 0 || | |
| 59 | + | len(i.extNotes) > 0 || | |
| 60 | + | i.dialog.Active() || | |
| 61 | + | i.modelDialog.Active() || | |
| 62 | + | i.llamaDialog.Active() || | |
| 63 | + | i.rescueDialog.Active() || | |
| 64 | + | i.sessionDialog.Active() || | |
| 65 | + | i.residentSubagentsDialog.Active() || | |
| 66 | + | i.residentChildSession != nil || | |
| 67 | + | i.jumpDialog.Active() || | |
| 68 | + | i.btwDialog.Active() || | |
| 69 | + | i.skillsDialog.Active() || | |
| 70 | + | i.changelogDialog.Active() || | |
| 71 | + | i.logoutDialog.Active() || | |
| 72 | + | i.telegramDialog.Active() || | |
| 73 | + | i.settingsDialog.Active() || | |
| 74 | + | i.sessionOpsDialog.Active() || | |
| 75 | + | i.sessionTreeDialog.Active() || | |
| 76 | + | i.extPanel.Active() | |
| 77 | + | } | |
| 78 | + | func (i *Interactive) restoreConfirmFocus() { | |
| 79 | + | if !i.confirmDialog.Active() || i.confirmDialog.Focused() { | |
| 80 | + | return | |
| 81 | + | } | |
| 82 | + | if !i.ed.IsEmpty() || i.confirmChildActive() { | |
| 83 | + | return | |
| 84 | + | } | |
| 85 | + | i.confirmDialog.Focus() | |
| 86 | + | i.invalidate() | |
| 87 | + | } | |
| 88 | + | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |
| 89 | + | defer i.restoreConfirmFocus() | |
| 90 | + | ||
| 91 | + | var ok bool | |
| 92 | + | if k, ok = i.prepareInputKey(k); !ok { | |
| 93 | + | return false | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // Dialogs consume keys while open (except ctrl+c, which always closes them). | |
| 97 | + | ||
| 98 | + | // Confirmation owns input by default. Pressing / deliberately moves | |
| 99 | + | // focus to the main editor without answering the pending request, so | |
| 100 | + | // slash commands and the dialogs they open can run first. | |
| 101 | + | if i.confirmDialog.Focused() { | |
| 102 | + | if k.Kind == tui.KeyRune && k.Rune == '/' { | |
| 103 | + | i.confirmDialog.Blur() | |
| 104 | + | i.ed.Insert("/") | |
| 105 | + | i.suggest.Reset() | |
| 106 | + | i.invalidate() | |
| 107 | + | return false | |
| 108 | + | } | |
| 109 | + | if k.Kind == tui.KeyCtrlO { | |
| 110 | + | if i.btwDialog != nil && i.btwDialog.Active() { | |
| 111 | + | i.toggleBtwToolExpansion() | |
| 112 | + | } else { | |
| 113 | + | i.toggleToolExpansion() | |
| 114 | + | } | |
| 115 | + | return false | |
| 116 | + | } | |
| 117 | + | i.confirmDialog.HandleKey(k) | |
| 118 | + | i.invalidate() | |
| 119 | + | return false | |
| 120 | + | } | |
| 121 | + | if i.confirmDialog.Active() && !i.confirmChildActive() && k.Kind == tui.KeyEsc { | |
| 122 | + | i.ed.Clear() | |
| 123 | + | i.suggest.Reset() | |
| 124 | + | i.fileSuggest.Reset() | |
| 125 | + | i.confirmDialog.Focus() | |
| 126 | + | i.invalidate() | |
| 127 | + | return false | |
| 128 | + | } | |
| 129 | + | if i.dialog.Active() { | |
| 130 | + | if k.Kind == tui.KeyCtrlC { | |
| 131 | + | i.dialog.Close() | |
| 132 | + | if i.cfg.AuthManager != nil { | |
| 133 | + | i.cfg.AuthManager.CancelOAuth() | |
| 134 | + | } | |
| 135 | + | return false | |
| 136 | + | } | |
| 137 | + | act := i.dialog.HandleKey(k) | |
| 138 | + | if act.StartAPIKey { | |
| 139 | + | i.startAPIKeyFlow(act.Provider) | |
| 140 | + | } | |
| 141 | + | if act.StartOAuth { | |
| 142 | + | i.startOAuthFlow(act.Provider) | |
| 143 | + | } | |
| 144 | + | if act.StartManual { | |
| 145 | + | i.startManualOAuthFlow(act.Provider) | |
| 146 | + | } | |
| 147 | + | if act.SubmitCode != "" { | |
| 148 | + | i.submitManualOAuthCode(act.SubmitCode) | |
| 149 | + | } | |
| 150 | + | if act.SaveLlama { | |
| 151 | + | i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey) | |
| 152 | + | } | |
| 153 | + | return false | |
| 154 | + | } | |
| 155 | + | if i.modelDialog.Active() { | |
| 156 | + | if k.Kind == tui.KeyCtrlC { | |
| 157 | + | i.modelDialog.Close() | |
| 158 | + | i.quickModelAssign = 0 | |
| 159 | + | return false | |
| 160 | + | } | |
| 161 | + | act := i.modelDialog.HandleKey(k) | |
| 162 | + | if act.Close { | |
| 163 | + | i.quickModelAssign = 0 | |
| 164 | + | } | |
| 165 | + | if act.ReasoningChanged { | |
| 166 | + | i.applyReasoningSetting(act.Reasoning) | |
| 167 | + | } | |
| 168 | + | if act.Select { | |
| 169 | + | if i.quickModelAssign > 0 { | |
| 170 | + | i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model) | |
| 171 | + | i.quickModelAssign = 0 | |
| 172 | + | } else { | |
| 173 | + | i.applyModelSelection(act.Provider, act.Model) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | return false | |
| 177 | + | } | |
| 178 | + | if i.llamaDialog.Active() { | |
| 179 | + | i.llamaDialog.HandleKey(k) | |
| 180 | + | i.invalidate() | |
| 181 | + | return false | |
| 182 | + | } | |
| 183 | + | if i.rescueDialog.Active() { | |
| 184 | + | if k.Kind == tui.KeyCtrlC { | |
| 185 | + | i.rescueDialog.Close() | |
| 186 | + | i.invalidate() | |
| 187 | + | return false | |
| 188 | + | } | |
| 189 | + | act := i.rescueDialog.HandleKey(k) | |
| 190 | + | if act.Select { | |
| 191 | + | i.applyRescueSelection(act.Provider, act.Model, act.Prompt) | |
| 192 | + | } | |
| 193 | + | i.invalidate() | |
| 194 | + | return false | |
| 195 | + | } | |
| 196 | + | i.mu.Lock() | |
| 197 | + | sessionDialogActive := i.sessionDialog.Active() | |
| 198 | + | i.mu.Unlock() | |
| 199 | + | if sessionDialogActive { | |
| 200 | + | if k.Kind == tui.KeyCtrlC { | |
| 201 | + | i.mu.Lock() | |
| 202 | + | i.sessionDialog.Close() | |
| 203 | + | i.sessionLoads = nil | |
| 204 | + | i.mu.Unlock() | |
| 205 | + | i.invalidate() | |
| 206 | + | return false | |
| 207 | + | } | |
| 208 | + | manualRenamePath := "" | |
| 209 | + | i.mu.Lock() | |
| 210 | + | if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil { | |
| 211 | + | manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path | |
| 212 | + | } | |
| 213 | + | i.mu.Unlock() | |
| 214 | + | manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath | |
| 215 | + | if manualRenameCurrent { | |
| 216 | + | i.markSessionTitleSwitching() | |
| 217 | + | } | |
| 218 | + | i.mu.Lock() | |
| 219 | + | act := i.sessionDialog.HandleKey(k) | |
| 220 | + | if act.Select || act.Close { | |
| 221 | + | i.sessionLoads = nil | |
| 222 | + | } | |
| 223 | + | i.mu.Unlock() | |
| 224 | + | if act.Select { | |
| 225 | + | i.applySessionSelection(act.Path) | |
| 226 | + | } | |
| 227 | + | if act.Err != nil { | |
| 228 | + | if manualRenameCurrent { | |
| 229 | + | i.restoreFailedSessionTitle() | |
| 230 | + | } | |
| 231 | + | i.mu.Lock() | |
| 232 | + | i.statusErr = "rename: " + act.Err.Error() | |
| 233 | + | i.statusOK = "" | |
| 234 | + | i.mu.Unlock() | |
| 235 | + | } else if act.Renamed && act.Path != "" && i.cfg.CurrentSessionPath != nil && i.cfg.CurrentSessionPath() == act.Path { | |
| 236 | + | i.setManualSessionTitle(act.RenameTitle) | |
| 237 | + | } | |
| 238 | + | // Always request a redraw after handling a key here: when esc | |
| 239 | + | // closes the picker, the overlay-close detection in the render | |
| 240 | + | // pass needs to run so the tall dialog rows get repainted from | |
| 241 | + | // the chat (otherwise VS Code's retained scrollback leaves a | |
| 242 | + | // duplicate frame on screen). | |
| 243 | + | i.invalidate() | |
| 244 | + | return false | |
| 245 | + | } | |
| 246 | + | i.mu.Lock() | |
| 247 | + | residentDialog := i.residentSubagentsDialog | |
| 248 | + | residentDialogActive := residentDialog != nil && residentDialog.Active() | |
| 249 | + | residentSession := i.residentChildSession | |
| 250 | + | i.mu.Unlock() | |
| 251 | + | if residentDialogActive { | |
| 252 | + | if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc { | |
| 253 | + | i.mu.Lock() | |
| 254 | + | if i.residentSubagentsDialog == residentDialog { | |
| 255 | + | residentDialog.Close() | |
| 256 | + | } | |
| 257 | + | i.mu.Unlock() | |
| 258 | + | i.invalidate() | |
| 259 | + | return false | |
| 260 | + | } | |
| 261 | + | i.mu.Lock() | |
| 262 | + | childID := residentDialog.HandleKey(k) | |
| 263 | + | i.mu.Unlock() | |
| 264 | + | if childID != "" { | |
| 265 | + | i.openResidentChildSession(childID) | |
| 266 | + | } | |
| 267 | + | i.invalidate() | |
| 268 | + | return false | |
| 269 | + | } | |
| 270 | + | if residentSession != nil { | |
| 271 | + | switch k.Kind { | |
| 272 | + | case tui.KeyCtrlC, tui.KeyEsc: | |
| 273 | + | i.mu.Lock() | |
| 274 | + | if i.residentChildSession == residentSession { | |
| 275 | + | i.residentChildSession = nil | |
| 276 | + | } | |
| 277 | + | i.mu.Unlock() | |
| 278 | + | case tui.KeyPageUp: | |
| 279 | + | session := residentSession | |
| 280 | + | if session.BeginLoad() { | |
| 281 | + | go func() { | |
| 282 | + | err := session.LoadOlder(200) | |
| 283 | + | if session.FinishLoad(err) { | |
| 284 | + | i.reloadResidentChildSession(session) | |
| 285 | + | } | |
| 286 | + | i.invalidate() | |
| 287 | + | }() | |
| 288 | + | } | |
| 289 | + | case tui.KeyPageDown: | |
| 290 | + | residentSession.FollowTail() | |
| 291 | + | case tui.KeyUp: | |
| 292 | + | residentSession.Scroll(1) | |
| 293 | + | case tui.KeyDown: | |
| 294 | + | residentSession.Scroll(-1) | |
| 295 | + | default: | |
| 296 | + | session := residentSession | |
| 297 | + | if prompt, submit := session.HandleKey(k); submit { | |
| 298 | + | go func() { | |
| 299 | + | err := i.cfg.ResidentManager.Resume(ctx, session.childID, prompt) | |
| 300 | + | session.FinishSubmission(err) | |
| 301 | + | i.invalidate() | |
| 302 | + | }() | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | i.invalidate() | |
| 306 | + | return false | |
| 307 | + | } | |
| 308 | + | if i.logoutDialog.Active() { | |
| 309 | + | if k.Kind == tui.KeyCtrlC { | |
| 310 | + | i.logoutDialog.Close() | |
| 311 | + | i.invalidate() | |
| 312 | + | return false | |
| 313 | + | } | |
| 314 | + | act := i.logoutDialog.HandleKey(k) | |
| 315 | + | if act.Select { | |
| 316 | + | i.doLogout(act.Target) | |
| 317 | + | } | |
| 318 | + | i.invalidate() | |
| 319 | + | return false | |
| 320 | + | } | |
| 321 | + | if i.telegramDialog.Active() { | |
| 322 | + | if k.Kind == tui.KeyCtrlC { | |
| 323 | + | i.telegramDialog.Close() | |
| 324 | + | i.invalidate() | |
| 325 | + | return false | |
| 326 | + | } | |
| 327 | + | act := i.telegramDialog.HandleKey(k) | |
| 328 | + | if act.Select { | |
| 329 | + | i.doTelegram(act.Action) | |
| 330 | + | } | |
| 331 | + | i.invalidate() | |
| 332 | + | return false | |
| 333 | + | } | |
| 334 | + | if i.settingsDialog.Active() { | |
| 335 | + | if k.Kind == tui.KeyCtrlC { | |
| 336 | + | i.settingsDialog.Close() | |
| 337 | + | i.invalidate() | |
| 338 | + | return false | |
| 339 | + | } | |
| 340 | + | act := i.settingsDialog.HandleKey(k) | |
| 341 | + | if act.ModelShortcutSlot > 0 { | |
| 342 | + | i.openQuickModelPicker(act.ModelShortcutSlot) | |
| 343 | + | } | |
| 344 | + | if act.Toggle { | |
| 345 | + | i.applySettingChange(act) | |
| 346 | + | } | |
| 347 | + | i.invalidate() | |
| 348 | + | return false | |
| 349 | + | } | |
| 350 | + | if i.sessionOpsDialog.Active() { | |
| 351 | + | if k.Kind == tui.KeyCtrlC { | |
| 352 | + | i.sessionOpsDialog.Close() | |
| 353 | + | i.invalidate() | |
| 354 | + | return false | |
| 355 | + | } | |
| 356 | + | act := i.sessionOpsDialog.HandleKey(k) | |
| 357 | + | if act.Select { | |
| 358 | + | i.doSessionOp(act.Action, "") | |
| 359 | + | } | |
| 360 | + | i.invalidate() | |
| 361 | + | return false | |
| 362 | + | } | |
| 363 | + | if i.sessionTreeDialog.Active() { | |
| 364 | + | if k.Kind == tui.KeyCtrlC { | |
| 365 | + | i.sessionTreeDialog.Close() | |
| 366 | + | i.invalidate() | |
| 367 | + | return false | |
| 368 | + | } | |
| 369 | + | act := i.sessionTreeDialog.HandleKey(k) | |
| 370 | + | if act.Select { | |
| 371 | + | if act.Target.Role != "" || act.Target.SourcePath != "" || act.Target.IsBoundary() || act.Target.UserDraft != "" { | |
| 372 | + | i.applySessionTreeTarget(act.Target, act.TurnNo) | |
| 373 | + | } else { | |
| 374 | + | // Keep package embedders that still return the scalar action | |
| 375 | + | // source-compatible while the dialog migrates to Target. | |
| 376 | + | i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt) | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | i.invalidate() | |
| 380 | + | return false | |
| 381 | + | } | |
| 382 | + | if i.extPanel.Active() { | |
| 383 | + | if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc { | |
| 384 | + | if i.cfg.Extensions != nil { | |
| 385 | + | _ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id) | |
| 386 | + | } | |
| 387 | + | i.extPanel.Close() | |
| 388 | + | i.invalidate() | |
| 389 | + | return false | |
| 390 | + | } | |
| 391 | + | if i.cfg.Extensions != nil { | |
| 392 | + | _ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k)) | |
| 393 | + | } | |
| 394 | + | return false | |
| 395 | + | } | |
| 396 | + | if i.jumpDialog.Active() { | |
| 397 | + | if k.Kind == tui.KeyCtrlC { | |
| 398 | + | i.jumpDialog.Close() | |
| 399 | + | i.pendingFork = false | |
| 400 | + | return false | |
| 401 | + | } | |
| 402 | + | act := i.jumpDialog.HandleKey(k) | |
| 403 | + | if act.Select { | |
| 404 | + | if i.pendingFork { | |
| 405 | + | i.applyForkSelection(act.MessageIdx) | |
| 406 | + | } else { | |
| 407 | + | i.applyJumpSelection(act.MessageIdx, act.TurnNo) | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | // If the user dismissed the dialog without selecting, also | |
| 411 | + | // clear the pending-fork flag so a later plain /jump isn't | |
| 412 | + | // hijacked. | |
| 413 | + | if act.Close { | |
| 414 | + | i.pendingFork = false | |
| 415 | + | } | |
| 416 | + | return false | |
| 417 | + | } | |
| 418 | + | if i.btwDialog.Active() { | |
| 419 | + | if k.Kind == tui.KeyCtrlC { | |
| 420 | + | i.btwDialog.Close() | |
| 421 | + | i.invalidate() | |
| 422 | + | return false | |
| 423 | + | } | |
| 424 | + | if k.Kind == tui.KeyCtrlO { | |
| 425 | + | i.toggleBtwToolExpansion() | |
| 426 | + | return false | |
| 427 | + | } | |
| 428 | + | i.btwDialog.HandleKey(k, i.invalidate) | |
| 429 | + | return false | |
| 430 | + | } | |
| 431 | + | if i.skillsDialog.Active() { | |
| 432 | + | if k.Kind == tui.KeyCtrlC { | |
| 433 | + | i.skillsDialog.Close() | |
| 434 | + | i.invalidate() | |
| 435 | + | return false | |
| 436 | + | } | |
| 437 | + | i.skillsDialog.HandleKey(k) | |
| 438 | + | i.invalidate() | |
| 439 | + | return false | |
| 440 | + | } | |
| 441 | + | if i.changelogDialog.Active() { | |
| 442 | + | if closed := i.changelogDialog.HandleKey(k); closed { | |
| 443 | + | // User dismissed; let the parent persist the | |
| 444 | + | // LastChangelogShown marker via the close callback. | |
| 445 | + | if i.cfg.OnChangelogDismiss != nil { | |
| 446 | + | i.cfg.OnChangelogDismiss() | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | i.invalidate() | |
| 450 | + | return false | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | if slot := quickModelShortcutSlot(k); slot > 0 { | |
| 454 | + | i.applyQuickModelShortcut(slot) | |
| 455 | + | return false | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | // The first eligible bare Escape is deliberately left to the existing | |
| 459 | + | // Escape path (which clears idle input or cancels a turn). A second tap | |
| 460 | + | // within the gesture window only gets first refusal while the cheap, | |
| 461 | + | // local eligibility rules still hold. If a turn started, a message was | |
| 462 | + | // queued, or the input became ineligible between taps, reset the detector | |
| 463 | + | // and fall through so Escape retains its normal cancellation behavior. | |
| 464 | + | // Once those rules still hold, the full tree gate owns the second tap: | |
| 465 | + | // family/read failures are fail-closed but are nevertheless consumed so | |
| 466 | + | // they cannot fall through into ordinary editor handling. | |
| 467 | + | if isUnmodifiedEscape(k) { | |
| 468 | + | now := i.sessionTreeEscapeNow() | |
| 469 | + | i.mu.Lock() | |
| 470 | + | consumed := i.doubleEscape.Consume(now) | |
| 471 | + | i.mu.Unlock() | |
| 472 | + | if consumed { | |
| 473 | + | if i.canArmSessionTreeEscape() { | |
| 474 | + | i.openSessionTree() | |
| 475 | + | return false | |
| 476 | + | } | |
| 477 | + | i.mu.Lock() | |
| 478 | + | i.doubleEscape.Reset() | |
| 479 | + | i.mu.Unlock() | |
| 480 | + | } else if i.canArmSessionTreeEscape() { | |
| 481 | + | i.mu.Lock() | |
| 482 | + | i.doubleEscape.Arm(now) | |
| 483 | + | i.mu.Unlock() | |
| 484 | + | } | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | // Global keys. | |
| 488 | + | switch k.Kind { | |
| 489 | + | case tui.KeyCtrlC: | |
| 490 | + | i.mu.Lock() | |
| 491 | + | loadingSession := i.sessionLoading | |
| 492 | + | i.mu.Unlock() | |
| 493 | + | if loadingSession { | |
| 494 | + | i.markCtrlCExit() | |
| 495 | + | return true | |
| 496 | + | } | |
| 497 | + | // While busy: do NOT cancel the turn. ctrl+c during a | |
| 498 | + | // running turn is almost always reflex muscle memory | |
| 499 | + | // ("be quiet" in a shell) rather than a deliberate | |
| 500 | + | // decision to kill a multi-minute model call that's | |
| 501 | + | // already cost tokens. Use esc to interrupt a turn; use | |
| 502 | + | // a deliberate double-ctrl+c to exit zut entirely. First | |
| 503 | + | // press arms the exit hint, second press within | |
| 504 | + | // ctrlCExitWindow quits. | |
| 505 | + | if i.busy { | |
| 506 | + | if i.ctrlCExitArmed() { | |
| 507 | + | i.markCtrlCExit() | |
| 508 | + | return true | |
| 509 | + | } | |
| 510 | + | i.mu.Lock() | |
| 511 | + | i.statusOK = "press ctrl+c again to exit, esc to cancel the turn" | |
| 512 | + | i.statusErr = "" | |
| 513 | + | i.mu.Unlock() | |
| 514 | + | i.armCtrlCExit() | |
| 515 | + | return false | |
| 516 | + | } | |
| 517 | + | // Idle: first press clears the editor (and any queued | |
| 518 | + | // follow-up messages); a second press within ctrlCExitWindow | |
| 519 | + | // exits. With both an empty editor and no queue the first | |
| 520 | + | // press still just arms — require a deliberate double-tap. | |
| 521 | + | ag := i.agent | |
| 522 | + | pending := 0 | |
| 523 | + | if ag != nil { | |
| 524 | + | pending = ag.QueuedMessageCount() | |
| 525 | + | } | |
| 526 | + | hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0 | |
| 527 | + | if hadInput { | |
| 528 | + | i.ed.Clear() | |
| 529 | + | i.clipboardImages = nil | |
| 530 | + | i.suggest.Reset() | |
| 531 | + | if ag != nil { | |
| 532 | + | ag.DrainQueuedMessages() | |
| 533 | + | } | |
| 534 | + | i.mu.Lock() | |
| 535 | + | i.queued = nil | |
| 536 | + | i.statusOK = "input cleared" | |
| 537 | + | i.statusErr = "" | |
| 538 | + | i.mu.Unlock() | |
| 539 | + | i.armCtrlCExit() | |
| 540 | + | return false | |
| 541 | + | } | |
| 542 | + | if i.ctrlCExitArmed() { | |
| 543 | + | i.markCtrlCExit() | |
| 544 | + | return true | |
| 545 | + | } | |
| 546 | + | i.mu.Lock() | |
| 547 | + | i.statusOK = "press ctrl+c again to exit" | |
| 548 | + | i.statusErr = "" | |
| 549 | + | i.mu.Unlock() | |
| 550 | + | i.armCtrlCExit() | |
| 551 | + | return false | |
| 552 | + | case tui.KeyEsc: | |
| 553 | + | // Esc interrupts a running turn — but only when nothing | |
| 554 | + | // else on screen wants to consume the key first. The slash | |
| 555 | + | // popup has its own Esc behaviour (close + clear editor), | |
| 556 | + | // and transient overlays like the /help block and extension | |
| 557 | + | // notes should dismiss on Esc before we even consider the | |
| 558 | + | // turn. Without these guards, a casual Esc press after | |
| 559 | + | // running /help on a busy turn rips the turn away. | |
| 560 | + | if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) { | |
| 561 | + | break | |
| 562 | + | } | |
| 563 | + | i.mu.Lock() | |
| 564 | + | hadHelp := len(i.helpBlock) > 0 | |
| 565 | + | hadNotes := len(i.extNotes) > 0 | |
| 566 | + | if hadHelp { | |
| 567 | + | i.helpBlock = nil | |
| 568 | + | } | |
| 569 | + | if hadNotes { | |
| 570 | + | i.extNotes = nil | |
| 571 | + | } | |
| 572 | + | i.mu.Unlock() | |
| 573 | + | if hadHelp || hadNotes { | |
| 574 | + | i.invalidate() | |
| 575 | + | return false | |
| 576 | + | } | |
| 577 | + | i.mu.Lock() | |
| 578 | + | busyCancel := i.busy && i.cancelTurn != nil | |
| 579 | + | cancelTurn := i.cancelTurn | |
| 580 | + | var restoreQueued string | |
| 581 | + | var handoff json.RawMessage | |
| 582 | + | var persistHandoff bool | |
| 583 | + | if busyCancel { | |
| 584 | + | // Keep the most recently queued follow-up as an editable draft | |
| 585 | + | // instead of losing it with the cancelled turn's stale queue. | |
| 586 | + | if i.agent != nil { | |
| 587 | + | restoreQueued, _ = i.agent.PopQueuedMessage() | |
| 588 | + | } | |
| 589 | + | if restoreQueued == "" && len(i.queued) > 0 { | |
| 590 | + | n := len(i.queued) - 1 | |
| 591 | + | restoreQueued = i.queued[n] | |
| 592 | + | i.queued = i.queued[:n] | |
| 593 | + | } | |
| 594 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 595 | + | } | |
| 596 | + | i.mu.Unlock() | |
| 597 | + | if busyCancel { | |
| 598 | + | if restoreQueued != "" { | |
| 599 | + | i.ed.SetValue(restoreQueued) | |
| 600 | + | i.inputHistoryIndex = -1 | |
| 601 | + | } | |
| 602 | + | i.updateActiveGoal(core.GoalPaused, "interrupted by user") | |
| 603 | + | cancelTurn() | |
| 604 | + | // If a confirm dialog is pending, refuse it so the agent | |
| 605 | + | // goroutine unblocks and the context cancellation can | |
| 606 | + | // actually take effect. | |
| 607 | + | i.confirmDialog.CancelAll("turn cancelled") | |
| 608 | + | if persistHandoff { | |
| 609 | + | i.persistCompactHandoff(handoff) | |
| 610 | + | } | |
| 611 | + | return false | |
| 612 | + | } | |
| 613 | + | case tui.KeyCtrlD: | |
| 614 | + | if i.ed.IsEmpty() && !i.busy { | |
| 615 | + | return true | |
| 616 | + | } | |
| 617 | + | case tui.KeyCtrlB: | |
| 618 | + | i.mu.Lock() | |
| 619 | + | i.rightBarHidden = !i.rightBarHidden | |
| 620 | + | i.mu.Unlock() | |
| 621 | + | i.requestRendererInvalidate() | |
| 622 | + | i.invalidate() | |
| 623 | + | return false | |
| 624 | + | case tui.KeyCtrlL: | |
| 625 | + | i.requestRendererClear() | |
| 626 | + | i.invalidate() | |
| 627 | + | return false | |
| 628 | + | case tui.KeyPasteClipboard: | |
| 629 | + | i.pasteClipboard(ctx) | |
| 630 | + | return false | |
| 631 | + | case tui.KeyCtrlO: | |
| 632 | + | i.toggleToolExpansion() | |
| 633 | + | return false | |
| 634 | + | case tui.KeyPageUp: | |
| 635 | + | i.scrollBy(+i.chatPage()) | |
| 636 | + | return false | |
| 637 | + | case tui.KeyPageDown: | |
| 638 | + | i.scrollBy(-i.chatPage()) | |
| 639 | + | return false | |
| 640 | + | case tui.KeyUp: | |
| 641 | + | // Alt/Option+Up: pop the most recently queued ("sliding in") | |
| 642 | + | // message back into the editor so the user can edit and | |
| 643 | + | // resend it. Repeated presses keep peeling messages off the | |
| 644 | + | // tail of the queue; each press *replaces* the editor | |
| 645 | + | // contents (we don't append/push). When the queue is empty | |
| 646 | + | // the keypress falls through to the normal scroll behavior. | |
| 647 | + | if k.Alt { | |
| 648 | + | i.mu.Lock() | |
| 649 | + | var text string | |
| 650 | + | if i.agent != nil { | |
| 651 | + | text, _ = i.agent.PopQueuedMessage() | |
| 652 | + | } | |
| 653 | + | if text == "" { | |
| 654 | + | if n := len(i.queued); n > 0 { | |
| 655 | + | text = i.queued[n-1] | |
| 656 | + | i.queued = i.queued[:n-1] | |
| 657 | + | } | |
| 658 | + | } | |
| 659 | + | i.mu.Unlock() | |
| 660 | + | if text != "" { | |
| 661 | + | i.ed.SetValue(text) | |
| 662 | + | i.inputHistoryIndex = -1 | |
| 663 | + | i.invalidate() | |
| 664 | + | return false | |
| 665 | + | } | |
| 666 | + | } | |
| 667 | + | // In multi-line / wrapped input, Up first moves inside the editor. | |
| 668 | + | // At the editor's top edge, plain Up can browse input history when | |
| 669 | + | // history browsing is safe/active; otherwise it falls back to chat | |
| 670 | + | // scrolling, preserving the old single-line scroll behavior. | |
| 671 | + | if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | |
| 672 | + | if i.ed.MoveVertical(-1) { | |
| 673 | + | i.invalidate() | |
| 674 | + | return false | |
| 675 | + | } | |
| 676 | + | if !k.Alt && i.handleInputHistoryKey(k) { | |
| 677 | + | return false | |
| 678 | + | } | |
| 679 | + | i.scrollBy(+3) | |
| 680 | + | return false | |
| 681 | + | } | |
| 682 | + | case tui.KeyDown: | |
| 683 | + | if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | |
| 684 | + | if i.ed.MoveVertical(+1) { | |
| 685 | + | i.invalidate() | |
| 686 | + | return false | |
| 687 | + | } | |
| 688 | + | if !k.Alt && i.inputHistoryIndex < 0 && i.cfg.ResidentManager != nil { | |
| 689 | + | if _, total := i.cfg.ResidentManager.RecentSnapshotPage(0, 1); total > 0 { | |
| 690 | + | i.runResidentSubagents(ctx, nil) | |
| 691 | + | return false | |
| 692 | + | } | |
| 693 | + | } | |
| 694 | + | if !k.Alt && i.handleInputHistoryKey(k) { | |
| 695 | + | return false | |
| 696 | + | } | |
| 697 | + | if i.scrollOffset > 0 { | |
| 698 | + | i.scrollBy(-3) | |
| 699 | + | } | |
| 700 | + | return false | |
| 701 | + | } | |
| 702 | + | } | |
| 703 | + | ||
| 704 | + | // Note: we intentionally do NOT gate the editor on i.busy here. | |
| 705 | + | // Typing while the agent is working is supported — submitted | |
| 706 | + | // messages are queued and delivered as follow-up turns when the | |
| 707 | + | // current turn ends. See the submit handler below. | |
| 708 | + | ||
| 709 | + | if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) { | |
| 710 | + | i.ed.HandleKey(k) | |
| 711 | + | return false | |
| 712 | + | } | |
| 713 | + | ||
| 714 | + | // Slash suggestions: intercept up/down/tab/enter when the popup is visible. | |
| 715 | + | if i.suggest.Active(i.ed.Value()) { | |
| 716 | + | switch k.Kind { | |
| 717 | + | case tui.KeyUp: | |
| 718 | + | i.suggest.Up() | |
| 719 | + | return false | |
| 720 | + | case tui.KeyDown: | |
| 721 | + | i.suggest.Down() | |
| 722 | + | return false | |
| 723 | + | case tui.KeyPageUp: | |
| 724 | + | i.suggest.PageUp() | |
| 725 | + | return false | |
| 726 | + | case tui.KeyPageDown: | |
| 727 | + | i.suggest.PageDown() | |
| 728 | + | return false | |
| 729 | + | case tui.KeyTab: | |
| 730 | + | if name := i.suggest.Selection(i.ed.Value()); name != "" { | |
| 731 | + | i.ed.SetValue(name) | |
| 732 | + | i.suggest.Reset() | |
| 733 | + | } | |
| 734 | + | return false | |
| 735 | + | case tui.KeyEnter: | |
| 736 | + | // Enter on an ambiguous or partial slash prefix: complete to the | |
| 737 | + | // currently highlighted command and run it. That way typing | |
| 738 | + | // "/lo" + enter picks whichever of /login or /logout is selected | |
| 739 | + | // in the popup instead of submitting "/lo" as unknown. Also | |
| 740 | + | // clear the editor so the command doesn't linger after the | |
| 741 | + | // dialog opens/closes. | |
| 742 | + | if name := i.suggest.Selection(i.ed.Value()); name != "" { | |
| 743 | + | i.ed.Clear() | |
| 744 | + | i.suggest.Reset() | |
| 745 | + | return i.runSlash(ctx, name) | |
| 746 | + | } | |
| 747 | + | case tui.KeyEsc: | |
| 748 | + | i.ed.Clear() | |
| 749 | + | i.suggest.Reset() | |
| 750 | + | return false | |
| 751 | + | } | |
| 752 | + | } | |
| 753 | + | ||
| 754 | + | // File suggestions: intercept up/down/tab/enter when the @-popup is visible. | |
| 755 | + | if i.fileSuggest.Active(i.ed.Value()) { | |
| 756 | + | switch k.Kind { | |
| 757 | + | case tui.KeyUp: | |
| 758 | + | i.fileSuggest.Up() | |
| 759 | + | return false | |
| 760 | + | case tui.KeyDown: | |
| 761 | + | i.fileSuggest.Down() | |
| 762 | + | return false | |
| 763 | + | case tui.KeyRight: | |
| 764 | + | // Open selected directory. The filter the user typed picked | |
| 765 | + | // that directory at the current level; once we descend it no | |
| 766 | + | // longer applies to the directory's contents, so clear it. | |
| 767 | + | // Otherwise typing "@eda" then right would re-filter inside | |
| 768 | + | // eda/ by "eda" and show nothing. | |
| 769 | + | if i.fileSuggest.Right() { | |
| 770 | + | i.clearFileSuggestQuery() | |
| 771 | + | } | |
| 772 | + | return false | |
| 773 | + | case tui.KeyLeft: | |
| 774 | + | // Go back to parent directory. Clear the filter for the same | |
| 775 | + | // reason as Right: it was scoped to the level we just left. | |
| 776 | + | if i.fileSuggest.Left() { | |
| 777 | + | i.clearFileSuggestQuery() | |
| 778 | + | } | |
| 779 | + | return false | |
| 780 | + | case tui.KeyEnter: | |
| 781 | + | if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok { | |
| 782 | + | var chip string | |
| 783 | + | if entry.isDir { | |
| 784 | + | chip = "[dir:" + entry.rel + "/]" | |
| 785 | + | } else { | |
| 786 | + | chip = "[file:" + entry.rel + "]" | |
| 787 | + | } | |
| 788 | + | val := i.ed.Value() | |
| 789 | + | if idx := strings.LastIndex(val, "@"); idx >= 0 { | |
| 790 | + | val = val[:idx] | |
| 791 | + | } | |
| 792 | + | i.ed.SetValue(val + chip + " ") | |
| 793 | + | i.fileSuggest.Reset() | |
| 794 | + | } | |
| 795 | + | return false | |
| 796 | + | case tui.KeyEsc: | |
| 797 | + | val := i.ed.Value() | |
| 798 | + | if idx := strings.LastIndex(val, "@"); idx >= 0 { | |
| 799 | + | i.ed.SetValue(val[:idx]) | |
| 800 | + | } | |
| 801 | + | i.fileSuggest.Reset() | |
| 802 | + | return false | |
| 803 | + | } | |
| 804 | + | } | |
| 805 | + | ||
| 806 | + | // Tab-complete a path token in the editor when no popup is open. | |
| 807 | + | // Recognises tokens that look like paths (start with ~, /, ./, ../ | |
| 808 | + | // or contain a slash); shell-style completion expands ~, lists the | |
| 809 | + | // parent dir, and completes the basename to the longest common | |
| 810 | + | // prefix. Single match: full replace and trailing / for dirs. | |
| 811 | + | // No match: no-op. Plain bare words (no slash, no tilde) fall | |
| 812 | + | // through so Tab keeps its current no-op behaviour outside paths. | |
| 813 | + | if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | |
| 814 | + | if i.tryPathTabComplete() { | |
| 815 | + | return false | |
| 816 | + | } | |
| 817 | + | } | |
| 818 | + | ||
| 819 | + | if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown { | |
| 820 | + | i.inputHistoryIndex = -1 | |
| 821 | + | } | |
| 822 | + | ||
| 823 | + | if k.Kind == tui.KeyEsc { | |
| 824 | + | i.clipboardImages = nil | |
| 825 | + | } | |
| 826 | + | if submit := i.ed.HandleKey(k); submit { | |
| 827 | + | // SubmitValue() expands any [pasted text #N +L lines] | |
| 828 | + | // placeholders back into their bodies; the raw Value() | |
| 829 | + | // is only what the user sees on screen. | |
| 830 | + | text := strings.TrimRight(i.ed.SubmitValue(), "\n") | |
| 831 | + | // Expand [file:name] and [dir:name/] chips to full paths. | |
| 832 | + | text = expandFileChips(text, i.cfg.CWD) | |
| 833 | + | text, images := preparePromptWithClipboardImages(text, i.clipboardImages) | |
| 834 | + | if text == "" && len(images) == 0 { | |
| 835 | + | return false | |
| 836 | + | } | |
| 837 | + | clearSubmittedInput := func() { | |
| 838 | + | i.clipboardImages = nil | |
| 839 | + | i.ed.Clear() | |
| 840 | + | i.inputHistoryIndex = -1 | |
| 841 | + | i.suggest.Reset() | |
| 842 | + | i.fileSuggest.Reset() | |
| 843 | + | } | |
| 844 | + | ||
| 845 | + | // Shell escapes and slash commands are text-only. An image-bearing | |
| 846 | + | // draft remains a normal provider prompt instead of silently dropping | |
| 847 | + | // its attachments. | |
| 848 | + | if len(images) == 0 { | |
| 849 | + | if cmd, ok := shellEscapeCommand(text); ok { | |
| 850 | + | clearSubmittedInput() | |
| 851 | + | i.startShellEscape(ctx, cmd) | |
| 852 | + | return false | |
| 853 | + | } | |
| 854 | + | } | |
| 855 | + | ||
| 856 | + | if len(images) == 0 && looksLikeSlashCommand(text) { | |
| 857 | + | clearSubmittedInput() | |
| 858 | + | head := text | |
| 859 | + | rest := "" | |
| 860 | + | if idx := strings.IndexAny(text, " \t"); idx >= 0 { | |
| 861 | + | head = text[:idx] | |
| 862 | + | rest = strings.TrimSpace(text[idx:]) | |
| 863 | + | } | |
| 864 | + | if !isKnownSlashCommand(text) { | |
| 865 | + | // Try extensions before giving up. Extensions register | |
| 866 | + | // commands by bare name (no leading slash); strip it here. | |
| 867 | + | extName := strings.TrimPrefix(head, "/") | |
| 868 | + | if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) { | |
| 869 | + | go i.invokeExtensionCommand(ctx, extName, rest) | |
| 870 | + | return false | |
| 871 | + | } | |
| 872 | + | i.mu.Lock() | |
| 873 | + | i.statusErr = "unknown command " + head + " — type /help to see the list" | |
| 874 | + | i.statusOK = "" | |
| 875 | + | i.mu.Unlock() | |
| 876 | + | return false | |
| 877 | + | } | |
| 878 | + | // Slash commands run regardless of busy state. Commands that | |
| 879 | + | // would mutate the transcript or replace the agent (/clear, | |
| 880 | + | // /compact, /logout, /login, /model, /fork, /session fork) | |
| 881 | + | // cancel the active turn | |
| 882 | + | // first and wait for the goroutine to wind down so they don't | |
| 883 | + | // race with a streaming response. Safe commands (/help, | |
| 884 | + | // /jump, /sessions, /jail, /unjail, /exit) run immediately | |
| 885 | + | // without disturbing the active turn. | |
| 886 | + | if slashCommandCancelsTurn(text) { | |
| 887 | + | i.cancelAndWaitForIdle() | |
| 888 | + | } | |
| 889 | + | return i.runSlash(ctx, text) | |
| 890 | + | } | |
| 891 | + | ||
| 892 | + | if i.agent == nil { | |
| 893 | + | i.mu.Lock() | |
| 894 | + | i.statusErr = "not logged in. type /login first." | |
| 895 | + | i.mu.Unlock() | |
| 896 | + | return false | |
| 897 | + | } | |
| 898 | + | // Mirror the user's typed prompt into the paired Telegram | |
| 899 | + | // chat (when the bridge is active) so the Telegram thread | |
| 900 | + | // stays a complete record of the session, not just the half | |
| 901 | + | // that originated on the phone. On a goroutine so the | |
| 902 | + | // network write doesn't delay the local turn. | |
| 903 | + | if i.telegramBridge != nil && i.telegramBridge.Active() { | |
| 904 | + | go i.telegramBridge.OnUserTyped(text) | |
| 905 | + | } | |
| 906 | + | i.maybeStartSessionTitle(ctx, text) | |
| 907 | + | // If a turn is already in flight, queue this prompt inside the | |
| 908 | + | // agent loop so it is delivered at the next safe model-call | |
| 909 | + | // boundary instead of waiting for the whole run to finish. | |
| 910 | + | i.mu.Lock() | |
| 911 | + | busy := i.busy | |
| 912 | + | compacting := i.compacting | |
| 913 | + | ag := i.agent | |
| 914 | + | i.mu.Unlock() | |
| 915 | + | if busy { | |
| 916 | + | if len(images) > 0 { | |
| 917 | + | i.mu.Lock() | |
| 918 | + | i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish" | |
| 919 | + | i.mu.Unlock() | |
| 920 | + | i.invalidate() | |
| 921 | + | return false | |
| 922 | + | } | |
| 923 | + | clearSubmittedInput() | |
| 924 | + | var handoff json.RawMessage | |
| 925 | + | var persistHandoff bool | |
| 926 | + | if ag != nil && !compacting { | |
| 927 | + | i.mu.Lock() | |
| 928 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 929 | + | ag.QueueMessage(text) | |
| 930 | + | i.mu.Unlock() | |
| 931 | + | } else { | |
| 932 | + | i.mu.Lock() | |
| 933 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 934 | + | i.queued = append(i.queued, text) | |
| 935 | + | i.mu.Unlock() | |
| 936 | + | } | |
| 937 | + | if persistHandoff { | |
| 938 | + | i.persistCompactHandoff(handoff) | |
| 939 | + | } | |
| 940 | + | i.invalidate() | |
| 941 | + | return false | |
| 942 | + | } | |
| 943 | + | clearSubmittedInput() | |
| 944 | + | i.startTurnWithImages(ctx, text, images) | |
| 945 | + | } | |
| 946 | + | return false | |
| 947 | + | } | |
| 948 | + | func (i *Interactive) pasteClipboard(ctx context.Context) { | |
| 949 | + | image, ok, _ := tui.ReadClipboardImage(ctx) | |
| 950 | + | if ok { | |
| 951 | + | i.mu.Lock() | |
| 952 | + | marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1) | |
| 953 | + | i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{ | |
| 954 | + | Marker: marker, | |
| 955 | + | Image: provider.ImageBlock{MimeType: image.MimeType, Data: image.Data}, | |
| 956 | + | }) | |
| 957 | + | i.ed.Insert(marker + " ") | |
| 958 | + | i.statusOK = "" | |
| 959 | + | i.statusErr = "" | |
| 960 | + | i.mu.Unlock() | |
| 961 | + | return | |
| 962 | + | } | |
| 963 | + | key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText) | |
| 964 | + | i.mu.Lock() | |
| 965 | + | defer i.mu.Unlock() | |
| 966 | + | if err != nil { | |
| 967 | + | i.statusErr = "clipboard paste failed: " + err.Error() | |
| 968 | + | i.statusOK = "" | |
| 969 | + | return | |
| 970 | + | } | |
| 971 | + | if pastedText { | |
| 972 | + | i.ed.HandleKey(key) | |
| 973 | + | i.statusErr = "" | |
| 974 | + | i.statusOK = "" | |
| 975 | + | return | |
| 976 | + | } | |
| 977 | + | i.statusErr = "clipboard does not contain text or an image" | |
| 978 | + | i.statusOK = "" | |
| 979 | + | } | |
| 980 | + | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | |
| 981 | + | if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown { | |
| 982 | + | return false | |
| 983 | + | } | |
| 984 | + | // Do not steal normal vertical cursor movement. History browsing can only | |
| 985 | + | // start from an empty editor; once active, Up/Down keep walking | |
| 986 | + | // the ring so repeated presses work even though the editor now | |
| 987 | + | // contains the selected historical prompt. | |
| 988 | + | if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() { | |
| 989 | + | return false | |
| 990 | + | } | |
| 991 | + | hist := i.inputHistory() | |
| 992 | + | if len(hist) == 0 { | |
| 993 | + | return false | |
| 994 | + | } | |
| 995 | + | ||
| 996 | + | if i.inputHistoryIndex < 0 { | |
| 997 | + | // Start just after the newest item so Up lands on the most | |
| 998 | + | // recent user prompt. A lone Down from an empty editor is not | |
| 999 | + | // history navigation; let the caller fall through to normal UI | |
| 1000 | + | // behavior instead. | |
| 1001 | + | if k.Kind != tui.KeyUp { | |
| 1002 | + | return false | |
| 1003 | + | } | |
| 1004 | + | i.inputHistoryIndex = len(hist) | |
| 1005 | + | } | |
| 1006 | + | ||
| 1007 | + | switch k.Kind { | |
| 1008 | + | case tui.KeyUp: | |
| 1009 | + | if i.inputHistoryIndex > 0 { | |
| 1010 | + | i.inputHistoryIndex-- | |
| 1011 | + | } | |
| 1012 | + | case tui.KeyDown: | |
| 1013 | + | if i.inputHistoryIndex < len(hist) { | |
| 1014 | + | i.inputHistoryIndex++ | |
| 1015 | + | } | |
| 1016 | + | } | |
| 1017 | + | ||
| 1018 | + | if i.inputHistoryIndex >= len(hist) { | |
| 1019 | + | i.ed.Clear() | |
| 1020 | + | } else { | |
| 1021 | + | i.ed.SetValue(hist[i.inputHistoryIndex]) | |
| 1022 | + | } | |
| 1023 | + | return true | |
| 1024 | + | } | |
| 1025 | + | func (i *Interactive) inputHistory() []string { | |
| 1026 | + | if i.agent == nil { | |
| 1027 | + | return nil | |
| 1028 | + | } | |
| 1029 | + | msgs := i.agent.Messages() | |
| 1030 | + | hist := make([]string, 0, len(msgs)) | |
| 1031 | + | for _, m := range msgs { | |
| 1032 | + | if !isForkableUserMessage(m) { | |
| 1033 | + | continue | |
| 1034 | + | } | |
| 1035 | + | text := userMessageText(m) | |
| 1036 | + | if strings.TrimSpace(text) == "" { | |
| 1037 | + | continue | |
| 1038 | + | } | |
| 1039 | + | hist = append(hist, text) | |
| 1040 | + | } | |
| 1041 | + | return hist | |
| 1042 | + | } | |
| 1043 | + | func userMessageText(m provider.Message) string { | |
| 1044 | + | var sb strings.Builder | |
| 1045 | + | for _, c := range m.Content { | |
| 1046 | + | if tb, ok := c.(provider.TextBlock); ok { | |
| 1047 | + | if sb.Len() > 0 { | |
| 1048 | + | sb.WriteByte('\n') | |
| 1049 | + | } | |
| 1050 | + | sb.WriteString(tb.Text) | |
| 1051 | + | } | |
| 1052 | + | } | |
| 1053 | + | return sb.String() | |
| 1054 | + | } | |
M packages/agent/modes/interactive_render.go +41 -0
| @@ -99,3 +99,44 @@ func (s *latestFrameScheduler) stop() { | |||
| 99 | 99 | s.mu.Unlock() | |
| 100 | 100 | <-s.done | |
| 101 | 101 | } | |
| 102 | + | ||
| 103 | + | func (i *Interactive) requestRendererClear() { | |
| 104 | + | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |
| 105 | + | scheduler.request(true, false) | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | if i.rend != nil { | |
| 109 | + | i.rend.Clear() | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (i *Interactive) requestRendererInvalidate() { | |
| 114 | + | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |
| 115 | + | scheduler.request(false, true) | |
| 116 | + | return | |
| 117 | + | } | |
| 118 | + | if i.rend != nil { | |
| 119 | + | i.rend.Invalidate() | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (i *Interactive) requestRendererTheme(theme tui.Theme) { | |
| 124 | + | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |
| 125 | + | scheduler.requestTheme(theme) | |
| 126 | + | return | |
| 127 | + | } | |
| 128 | + | if i.rend != nil { | |
| 129 | + | i.rend.SetTheme(theme) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func (i *Interactive) invalidate() { | |
| 134 | + | i.renderRevision.Add(1) | |
| 135 | + | // Keep ordinary state changes on the main-loop throttle. The scheduler | |
| 136 | + | // owns terminal output, but scheduling it here would bypass | |
| 137 | + | // redrawMinInterval and paint every intermediate tool-event frame. | |
| 138 | + | select { | |
| 139 | + | case i.dirty <- struct{}{}: | |
| 140 | + | default: | |
| 141 | + | } | |
| 142 | + | } | |
A packages/agent/modes/interactive_rendering.go +1234 -0
| @@ -0,0 +1,1234 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "sort" | |
| 6 | + | "strings" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zut/packages/agent/extproto" | |
| 10 | + | "github.com/bnema/zut/packages/agent/skills" | |
| 11 | + | "github.com/bnema/zut/packages/provider" | |
| 12 | + | "github.com/bnema/zut/packages/tui" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func (i *Interactive) cachedChatLocked(cols int) []string { | |
| 16 | + | // A busy frame contains mutable streaming/tool state. Keep the stable | |
| 17 | + | // transcript cache, but never return a full-frame cache entry that would | |
| 18 | + | // hide a newly arrived delta or completed result. | |
| 19 | + | if i.busy || i.streamOn || i.streamFlushPending { | |
| 20 | + | return i.buildChatLocked(cols) | |
| 21 | + | } | |
| 22 | + | key := i.chatCacheKeyLocked(cols) | |
| 23 | + | if i.chatCacheValid && i.chatCacheKey == key { | |
| 24 | + | return append([]string(nil), i.chatCache...) | |
| 25 | + | } | |
| 26 | + | chat := i.buildChatLocked(cols) | |
| 27 | + | key = i.chatCacheKeyLocked(cols) | |
| 28 | + | i.chatCache = append(i.chatCache[:0], chat...) | |
| 29 | + | i.chatCacheKey = key | |
| 30 | + | i.chatCacheValid = true | |
| 31 | + | return chat | |
| 32 | + | } | |
| 33 | + | func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey { | |
| 34 | + | var rev uint64 | |
| 35 | + | if i.agent != nil { | |
| 36 | + | rev = i.agent.Revision() | |
| 37 | + | } | |
| 38 | + | showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration | |
| 39 | + | return chatCacheKey{ | |
| 40 | + | cols: cols, | |
| 41 | + | agentRev: rev, | |
| 42 | + | statusOK: i.statusOK, | |
| 43 | + | statusErr: i.statusErr, | |
| 44 | + | help: strings.Join(i.helpBlock, "\n"), | |
| 45 | + | extNotes: strings.Join(i.extNotes, "\n"), | |
| 46 | + | extStatuses: i.extensionStatusesKeyLocked(), | |
| 47 | + | extWidgets: i.extensionWidgetsKeyLocked(), | |
| 48 | + | reloadErrors: strings.Join(i.reloadErrors, "\n"), | |
| 49 | + | updateAvailable: i.updateInfo.Available, | |
| 50 | + | updateCurrent: i.updateInfo.Current, | |
| 51 | + | updateLatest: i.updateInfo.Latest, | |
| 52 | + | updateURL: i.updateInfo.URL, | |
| 53 | + | welcomeShowVer: showVer, | |
| 54 | + | expandAll: i.view.ExpandAll, | |
| 55 | + | tailLimit: i.view.TailLimit, | |
| 56 | + | renderedMessageCount: len(i.view.Messages), | |
| 57 | + | viewCacheRev: i.view.RenderCacheRevision, | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string { | |
| 61 | + | keys := make([]string, 0, len(groups)) | |
| 62 | + | for key := range groups { | |
| 63 | + | keys = append(keys, key) | |
| 64 | + | } | |
| 65 | + | sort.Strings(keys) | |
| 66 | + | return keys | |
| 67 | + | } | |
| 68 | + | func sortedNestedInnerKeys[T any](groups map[string]map[string]T, outer string) []string { | |
| 69 | + | items := groups[outer] | |
| 70 | + | keys := make([]string, 0, len(items)) | |
| 71 | + | for key := range items { | |
| 72 | + | keys = append(keys, key) | |
| 73 | + | } | |
| 74 | + | sort.Strings(keys) | |
| 75 | + | return keys | |
| 76 | + | } | |
| 77 | + | func (i *Interactive) extensionStatusesKeyLocked() string { | |
| 78 | + | var sb strings.Builder | |
| 79 | + | extNames := sortedNestedOuterKeys(i.extStatuses) | |
| 80 | + | for _, extName := range extNames { | |
| 81 | + | keys := sortedNestedInnerKeys(i.extStatuses, extName) | |
| 82 | + | for _, key := range keys { | |
| 83 | + | status := i.extStatuses[extName][key] | |
| 84 | + | fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, key, status.Level, status.Text) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | return sb.String() | |
| 88 | + | } | |
| 89 | + | func (i *Interactive) extensionWidgetsKeyLocked() string { | |
| 90 | + | var sb strings.Builder | |
| 91 | + | extNames := sortedNestedOuterKeys(i.extWidgets) | |
| 92 | + | for _, extName := range extNames { | |
| 93 | + | ids := sortedNestedInnerKeys(i.extWidgets, extName) | |
| 94 | + | for _, id := range ids { | |
| 95 | + | widget := i.extWidgets[extName][id] | |
| 96 | + | fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, id, widget.Position, widget.Title) | |
| 97 | + | for _, line := range widget.Lines { | |
| 98 | + | sb.WriteString(line) | |
| 99 | + | sb.WriteByte('\n') | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | return sb.String() | |
| 104 | + | } | |
| 105 | + | func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget { | |
| 106 | + | var out []tui.RightBarWidget | |
| 107 | + | for _, extName := range sortedNestedOuterKeys(i.extWidgets) { | |
| 108 | + | for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) { | |
| 109 | + | widget := i.extWidgets[extName][id] | |
| 110 | + | if extproto.NormalizeWidgetPosition(widget.Position) != extproto.WidgetPositionRightBar { | |
| 111 | + | continue | |
| 112 | + | } | |
| 113 | + | out = append(out, tui.RightBarWidget{ | |
| 114 | + | Extension: extName, | |
| 115 | + | ID: id, | |
| 116 | + | Title: widget.Title, | |
| 117 | + | Lines: append([]string(nil), widget.Lines...), | |
| 118 | + | }) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | return out | |
| 122 | + | } | |
| 123 | + | func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | |
| 124 | + | return i.extensionChromeLinesAtLocked(cols, false) | |
| 125 | + | } | |
| 126 | + | func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActive, rightBarFallback bool) []string { | |
| 127 | + | lines := i.extensionChromeLinesAtLocked(cols, rightBarActive) | |
| 128 | + | if !rightBarFallback || len(lines) <= maxNarrowExtensionChromeRows { | |
| 129 | + | return lines | |
| 130 | + | } | |
| 131 | + | limit := maxNarrowExtensionChromeRows | |
| 132 | + | trimmed := append([]string(nil), lines[:limit-1]...) | |
| 133 | + | trimmed = append(trimmed, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols))) | |
| 134 | + | return trimmed | |
| 135 | + | } | |
| 136 | + | func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string { | |
| 137 | + | var out []string | |
| 138 | + | bodyWidth := cols - 2 | |
| 139 | + | if bodyWidth < 1 { | |
| 140 | + | bodyWidth = 1 | |
| 141 | + | } | |
| 142 | + | widgetRows := 0 | |
| 143 | + | widgetTruncated := false | |
| 144 | + | appendWidgetRow := func(line string) bool { | |
| 145 | + | if widgetRows >= maxExtensionWidgetRows-1 { | |
| 146 | + | widgetTruncated = true | |
| 147 | + | return false | |
| 148 | + | } | |
| 149 | + | out = append(out, line) | |
| 150 | + | widgetRows++ | |
| 151 | + | return true | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | stopWidgets := false | |
| 155 | + | for _, extName := range sortedNestedOuterKeys(i.extWidgets) { | |
| 156 | + | if stopWidgets { | |
| 157 | + | break | |
| 158 | + | } | |
| 159 | + | for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) { | |
| 160 | + | widget := i.extWidgets[extName][id] | |
| 161 | + | if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar { | |
| 162 | + | continue | |
| 163 | + | } | |
| 164 | + | label := " [" + extName + "]" | |
| 165 | + | if widget.Title != "" { | |
| 166 | + | label += " " + widget.Title | |
| 167 | + | } | |
| 168 | + | if !appendWidgetRow(i.cfg.Theme.FGColor(i.cfg.Theme.Accent, truncateLine(label, cols))) { | |
| 169 | + | stopWidgets = true | |
| 170 | + | break | |
| 171 | + | } | |
| 172 | + | for _, line := range widget.Lines { | |
| 173 | + | if !appendWidgetRow(" " + truncateLine(line, bodyWidth)) { | |
| 174 | + | stopWidgets = true | |
| 175 | + | break | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | if stopWidgets { | |
| 179 | + | break | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | if widgetTruncated { | |
| 184 | + | out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension widgets truncated ...", cols))) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | statusRows := 0 | |
| 188 | + | statusTruncated := false | |
| 189 | + | for _, extName := range sortedNestedOuterKeys(i.extStatuses) { | |
| 190 | + | for _, key := range sortedNestedInnerKeys(i.extStatuses, extName) { | |
| 191 | + | if statusRows >= maxExtensionStatusRows-1 { | |
| 192 | + | statusTruncated = true | |
| 193 | + | break | |
| 194 | + | } | |
| 195 | + | status := i.extStatuses[extName][key] | |
| 196 | + | color := i.cfg.Theme.Muted | |
| 197 | + | switch status.Level { | |
| 198 | + | case "warn": | |
| 199 | + | color = i.cfg.Theme.Warning | |
| 200 | + | case "error": | |
| 201 | + | color = i.cfg.Theme.Error | |
| 202 | + | case "success": | |
| 203 | + | color = i.cfg.Theme.Tool | |
| 204 | + | } | |
| 205 | + | label := " [" + extName + "] " + status.Text | |
| 206 | + | out = append(out, i.cfg.Theme.FGColor(color, truncateLine(label, cols))) | |
| 207 | + | statusRows++ | |
| 208 | + | } | |
| 209 | + | if statusTruncated { | |
| 210 | + | break | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | if statusTruncated { | |
| 214 | + | out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension statuses truncated ...", cols))) | |
| 215 | + | } | |
| 216 | + | return out | |
| 217 | + | } | |
| 218 | + | func (i *Interactive) stableChatRowsLocked(cols int) []string { | |
| 219 | + | key := i.chatCacheKeyLocked(cols) | |
| 220 | + | if i.stableChatCacheValid && i.stableChatCacheKey == key { | |
| 221 | + | return append([]string(nil), i.stableChatCache...) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | renderView := i.view.CloneForRender() | |
| 225 | + | renderView.Streaming = "" | |
| 226 | + | renderView.StreamingActive = false | |
| 227 | + | renderView.ToolCalls = nil | |
| 228 | + | renderView.Err = "" | |
| 229 | + | if !i.renderOutsideLock { | |
| 230 | + | rows := renderView.Build(cols) | |
| 231 | + | i.stableChatCache = append(i.stableChatCache[:0], rows...) | |
| 232 | + | i.stableChatCacheKey = key | |
| 233 | + | i.stableChatCacheValid = true | |
| 234 | + | return append([]string(nil), rows...) | |
| 235 | + | } | |
| 236 | + | // Markdown, syntax highlighting, and wrapping are the expensive part of | |
| 237 | + | // a frame. Build the immutable snapshot without holding the interactive | |
| 238 | + | // mutex so key processing can continue while a cold transcript renders. | |
| 239 | + | i.mu.Unlock() | |
| 240 | + | rows := renderView.Build(cols) | |
| 241 | + | i.mu.Lock() | |
| 242 | + | if i.view.MessagesRevision == renderView.MessagesRevision && | |
| 243 | + | i.view.RenderCacheRevision == renderView.RenderCacheRevision { | |
| 244 | + | i.view.AdoptRenderCacheFrom(renderView) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | i.stableChatCache = append(i.stableChatCache[:0], rows...) | |
| 248 | + | i.stableChatCacheKey = key | |
| 249 | + | i.stableChatCacheValid = true | |
| 250 | + | return append([]string(nil), rows...) | |
| 251 | + | } | |
| 252 | + | func (i *Interactive) buildChatLocked(cols int) []string { | |
| 253 | + | if i.agent != nil { | |
| 254 | + | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |
| 255 | + | i.view.MessagesRevision = i.agent.Revision() | |
| 256 | + | } else { | |
| 257 | + | i.view.Messages = nil | |
| 258 | + | i.view.MessagesRevision = 0 | |
| 259 | + | } | |
| 260 | + | // Pacer flush: while the streaming pacer is still draining the | |
| 261 | + | // buffer (i.e. EvAssistantMessage already fired but more runes | |
| 262 | + | // are queued), the final assistant message is already in | |
| 263 | + | // i.agent.Messages() in full. Painting it in the transcript | |
| 264 | + | // AND the streaming block at the same time shows the user the | |
| 265 | + | // complete text immediately — which defeats the whole pacer. | |
| 266 | + | // Hide the last message until the pacer catches up; once the | |
| 267 | + | // flush-pending latch clears, the message is revealed (the | |
| 268 | + | // streaming block disappears the same frame because streamOn | |
| 269 | + | // flips off, so the transition is seamless). | |
| 270 | + | if i.streamFlushPending && len(i.view.Messages) > 0 { | |
| 271 | + | i.view.Messages = i.view.Messages[:len(i.view.Messages)-1] | |
| 272 | + | } | |
| 273 | + | i.view.Streaming = i.streaming.String() | |
| 274 | + | i.view.StreamingActive = i.streamOn | |
| 275 | + | // Guard against the narrow race where EvAssistantMessage has | |
| 276 | + | // just promoted a streaming reply into the transcript but a | |
| 277 | + | // render tick hasn't flipped streamOn off yet. Without the | |
| 278 | + | // guard, the same text would appear twice (once as the | |
| 279 | + | // in-flight streaming block, once as the last transcript | |
| 280 | + | // message). We detect the duplicate strictly: the last | |
| 281 | + | // assistant message's visible text must equal the streaming | |
| 282 | + | // buffer. Just matching on role is too broad — it also hides | |
| 283 | + | // the next round's typewriter streaming after a tool turn, | |
| 284 | + | // because the last transcript message is always assistant | |
| 285 | + | // (the tool-use block) until the follow-up summary lands. | |
| 286 | + | if i.streamOn && i.streaming.Len() > 0 { | |
| 287 | + | if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant { | |
| 288 | + | if assistantText(i.view.Messages[n-1]) == i.streaming.String() { | |
| 289 | + | i.view.StreamingActive = false | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | // Live tool-call view: only shown while a turn is in flight. Once | |
| 294 | + | // the agent is idle, every tool call has already been folded into | |
| 295 | + | // the transcript (as assistant.ToolCallBlock + a tool-role message), | |
| 296 | + | // so showing v.ToolCalls a second time would duplicate them below | |
| 297 | + | // the final assistant text — which looks like the summary came | |
| 298 | + | // "before" the tools. | |
| 299 | + | i.view.ToolCalls = i.view.ToolCalls[:0] | |
| 300 | + | if i.busy { | |
| 301 | + | for _, id := range i.toolOrder { | |
| 302 | + | // Deterministic ordering: a tool block stays hidden until | |
| 303 | + | // the paced assistant text that preceded it has finished | |
| 304 | + | // typing out. toolOrder is append-only in arrival order, | |
| 305 | + | // so once one tool is still gated, every later tool is too, | |
| 306 | + | // so stop here to avoid showing a tool out of sequence. | |
| 307 | + | if !i.toolGateOpenLocked(id) { | |
| 308 | + | break | |
| 309 | + | } | |
| 310 | + | if tc, ok := i.toolCalls[id]; ok { | |
| 311 | + | i.view.ToolCalls = append(i.view.ToolCalls, *tc) | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | i.view.Err = i.statusErr | |
| 316 | + | // Live streaming/tool rows are appended to the chat buffer (not | |
| 317 | + | // hoisted into a separate live block above the editor). That keeps | |
| 318 | + | // the renderer's diff view append-only: when a tool finishes the | |
| 319 | + | // rows update in place at the end of the buffer, instead of the | |
| 320 | + | // whole bottom band shrinking and shifting chat lines around. | |
| 321 | + | i.liveBlock = nil | |
| 322 | + | stable := i.stableChatRowsLocked(cols) | |
| 323 | + | chat := append([]string(nil), stable...) | |
| 324 | + | chat = append(chat, i.view.BuildLive(cols)...) | |
| 325 | + | ||
| 326 | + | // Welcome banner: shown at the top of the chat area when there is | |
| 327 | + | // no transcript yet. Disappears after the first message is sent. | |
| 328 | + | // The version suffix is shown for welcomeVersionDuration after | |
| 329 | + | // startup, then drops off automatically. | |
| 330 | + | if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 { | |
| 331 | + | showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration | |
| 332 | + | chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...) | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | // Update-available banner: prepended above everything else so it's | |
| 336 | + | // the first thing the user sees when opening a new zut session. | |
| 337 | + | // Once rendered, it stays until the user updates to a newer | |
| 338 | + | // version — we don't persist a "dismissed" flag because this is | |
| 339 | + | // cheap and re-showing it is how most users remember to update. | |
| 340 | + | if i.updateInfo.Available { | |
| 341 | + | banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols) | |
| 342 | + | chat = append(banner, chat...) | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | // /help block: appended to the transcript so it appears at the | |
| 346 | + | // bottom of the chat area (right above the status bar / editor). | |
| 347 | + | // Prepending it would push long conversations off the top of the | |
| 348 | + | // viewport, which users would miss entirely. | |
| 349 | + | if len(i.helpBlock) > 0 { | |
| 350 | + | chat = append(chat, i.helpBlock...) | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | if i.statusOK != "" { | |
| 354 | + | // Hard-truncate the OK line to the visible width so a long | |
| 355 | + | // session path ("resumed session: /Users/.../sessions/...") | |
| 356 | + | // doesn't overflow past the right edge and look broken on a | |
| 357 | + | // narrow terminal. | |
| 358 | + | line := "✓ " + i.statusOK | |
| 359 | + | if cols > 4 && len(line) > cols { | |
| 360 | + | line = line[:cols-3] + "..." | |
| 361 | + | } | |
| 362 | + | chat = append(chat, i.cfg.Theme.FGColor(i.cfg.Theme.Tool, line), "") | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | // Live shell-escape output (!command / entry.pre) streams into the | |
| 366 | + | // transcript area while the command runs, then is replaced by the | |
| 367 | + | // final user-context message when it finishes. | |
| 368 | + | if i.shellRunning && i.shellLive != "" { | |
| 369 | + | chat = append(chat, strings.Split(strings.TrimRight(i.shellLive, "\n"), "\n")...) | |
| 370 | + | chat = append(chat, "") | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | // Extension notes (notify / display) live just under the | |
| 374 | + | // transcript, above the dialog/editor band. Cleared by /clear. | |
| 375 | + | if len(i.extNotes) > 0 { | |
| 376 | + | chat = append(chat, i.extNotes...) | |
| 377 | + | chat = append(chat, "") | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | // Reload failures remain in the scrolling chat until /clear. They are | |
| 381 | + | // host-only display state, not provider messages or persisted context. | |
| 382 | + | // While the latest failure is still the temporary status error, hide its | |
| 383 | + | // stored copy to avoid painting the same message twice. | |
| 384 | + | reloadErrors := i.reloadErrors | |
| 385 | + | if n := len(reloadErrors); n > 0 && reloadErrors[n-1] == i.statusErr { | |
| 386 | + | reloadErrors = reloadErrors[:n-1] | |
| 387 | + | } | |
| 388 | + | if len(reloadErrors) > 0 { | |
| 389 | + | chat = append(chat, renderReloadErrors(i.cfg.Theme, reloadErrors, cols)...) | |
| 390 | + | chat = append(chat, "") | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | // Strip trailing blank rows so the chat content sits flush | |
| 394 | + | // against the new "blank above status bar" row added by the | |
| 395 | + | // bottom-region assembly. Build() ends every message with a | |
| 396 | + | // blank separator; without this trim, the final message in | |
| 397 | + | // the transcript would have its own trailing blank plus the | |
| 398 | + | // status block's leading blank, doubling the gap. | |
| 399 | + | for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" { | |
| 400 | + | chat = chat[:len(chat)-1] | |
| 401 | + | } | |
| 402 | + | return chat | |
| 403 | + | } | |
| 404 | + | func (i *Interactive) sessionsRoot() string { | |
| 405 | + | if i.cfg.SessionsRoot != "" { | |
| 406 | + | return i.cfg.SessionsRoot | |
| 407 | + | } | |
| 408 | + | return i.cfg.ZutHome | |
| 409 | + | } | |
| 410 | + | func (i *Interactive) lastCols() int { | |
| 411 | + | cols, _ := i.cfg.Terminal.Size() | |
| 412 | + | return cols | |
| 413 | + | } | |
| 414 | + | func (i *Interactive) chatPage() int { | |
| 415 | + | _, rows := i.cfg.Terminal.Size() | |
| 416 | + | p := rows - 6 // rough reservation for status + editor + a dialog line | |
| 417 | + | if p < 4 { | |
| 418 | + | p = 4 | |
| 419 | + | } | |
| 420 | + | return p | |
| 421 | + | } | |
| 422 | + | func (i *Interactive) scrollBy(delta int) { | |
| 423 | + | i.mu.Lock() | |
| 424 | + | i.scrollOffset += delta | |
| 425 | + | if i.scrollOffset < 0 { | |
| 426 | + | i.scrollOffset = 0 | |
| 427 | + | } | |
| 428 | + | if i.scrollOffset == 0 { | |
| 429 | + | i.parkedTurn = 0 | |
| 430 | + | i.parkedTotal = 0 | |
| 431 | + | } | |
| 432 | + | if i.rend != nil { | |
| 433 | + | // VS Code's terminal is especially prone to leaving stray | |
| 434 | + | // wrapped-character fragments behind during scroll-driven | |
| 435 | + | // viewport changes. Force a full repaint on scroll, but | |
| 436 | + | // avoid a whole-screen clear because that visibly flickers. | |
| 437 | + | i.requestRendererInvalidate() | |
| 438 | + | } | |
| 439 | + | i.mu.Unlock() | |
| 440 | + | i.invalidate() | |
| 441 | + | } | |
| 442 | + | func anchorScrollOffset(offset, prevLen, newLen, prevRows, newRows int) int { | |
| 443 | + | adj := (newLen - prevLen) - (newRows - prevRows) | |
| 444 | + | offset += adj | |
| 445 | + | if offset < 0 { | |
| 446 | + | offset = 0 | |
| 447 | + | } | |
| 448 | + | if offset > newLen { | |
| 449 | + | offset = newLen | |
| 450 | + | } | |
| 451 | + | return offset | |
| 452 | + | } | |
| 453 | + | func (i *Interactive) scrollToBottom() { | |
| 454 | + | i.mu.Lock() | |
| 455 | + | i.scrollOffset = 0 | |
| 456 | + | i.parkedTurn = 0 | |
| 457 | + | i.parkedTotal = 0 | |
| 458 | + | // Reset the auto-follow baseline. scrollToBottom is the resume / | |
| 459 | + | // session-swap snap point, where the chat buffer changes length | |
| 460 | + | // wholesale. Without zeroing these, the next render's follow guard | |
| 461 | + | // compares the fresh transcript's length against a stale baseline | |
| 462 | + | // and nudges scrollOffset, which reads as a viewport jump right | |
| 463 | + | // after resume. See commit 43da5e5 for the same fix on new turns. | |
| 464 | + | i.prevChatLen = 0 | |
| 465 | + | i.prevChatCols = 0 | |
| 466 | + | i.requestRendererInvalidate() | |
| 467 | + | i.mu.Unlock() | |
| 468 | + | i.invalidate() | |
| 469 | + | } | |
| 470 | + | func (i *Interactive) redraw() { | |
| 471 | + | i.mu.Lock() | |
| 472 | + | renderRevision := i.renderRevision.Load() | |
| 473 | + | ||
| 474 | + | cols, rows := i.cfg.Terminal.Size() | |
| 475 | + | mainCols := cols | |
| 476 | + | rightBarWidgets := i.rightBarWidgetsLocked() | |
| 477 | + | rightBarActive := false | |
| 478 | + | var rightBarWidth int | |
| 479 | + | if !i.rightBarHidden && len(rightBarWidgets) > 0 { | |
| 480 | + | if width, rail, ok := tui.RightBarColumns(cols); ok { | |
| 481 | + | mainCols = width | |
| 482 | + | rightBarWidth = rail | |
| 483 | + | rightBarActive = true | |
| 484 | + | } | |
| 485 | + | } | |
| 486 | + | rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive | |
| 487 | + | i.renderOutsideLock = true | |
| 488 | + | chat := i.cachedChatLocked(mainCols) | |
| 489 | + | i.renderOutsideLock = false | |
| 490 | + | if i.renderRevision.Load() != renderRevision { | |
| 491 | + | i.mu.Unlock() | |
| 492 | + | return | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | // Dialogs (login or model picker) render between chat and the editor. | |
| 496 | + | var dialog []string | |
| 497 | + | switch { | |
| 498 | + | case i.dialog.Active(): | |
| 499 | + | dialog = i.dialog.Render(i.cfg.Theme, mainCols) | |
| 500 | + | case i.modelDialog.Active(): | |
| 501 | + | dialog = i.modelDialog.Render(i.cfg.Theme, mainCols) | |
| 502 | + | case i.llamaDialog.Active(): | |
| 503 | + | dialog = i.llamaDialog.Render(i.cfg.Theme, mainCols) | |
| 504 | + | case i.rescueDialog.Active(): | |
| 505 | + | dialog = i.rescueDialog.Render(i.cfg.Theme, mainCols) | |
| 506 | + | case i.sessionDialog.Active(): | |
| 507 | + | // Reserve rows for the editor (~3), status line (1-2), | |
| 508 | + | // dialog chrome (header + hint + rule + indicators, ~5), | |
| 509 | + | // and leave the remainder for session rows. Minimum of 3 | |
| 510 | + | // rows so even a very small terminal shows something. | |
| 511 | + | _, rows := i.cfg.Terminal.Size() | |
| 512 | + | avail := rows - 12 | |
| 513 | + | if avail < 3 { | |
| 514 | + | avail = 3 | |
| 515 | + | } | |
| 516 | + | i.sessionDialog.MaxRows = avail | |
| 517 | + | dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols) | |
| 518 | + | case i.residentSubagentsDialog.Active(): | |
| 519 | + | _, terminalRows := i.cfg.Terminal.Size() | |
| 520 | + | dialog = i.residentSubagentsDialog.Render(i.cfg.Theme, mainCols, terminalRows) | |
| 521 | + | case i.residentChildSession != nil: | |
| 522 | + | _, terminalRows := i.cfg.Terminal.Size() | |
| 523 | + | dialog = i.residentChildSession.Render(mainCols, terminalRows) | |
| 524 | + | case i.jumpDialog.Active(): | |
| 525 | + | dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols) | |
| 526 | + | case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()): | |
| 527 | + | dialog = i.extPanel.Render(i.cfg.Theme, mainCols) | |
| 528 | + | case i.confirmDialog.Active(): | |
| 529 | + | if i.btwDialog.Active() { | |
| 530 | + | // Keep the side-chat transcript and its live tool preview visible | |
| 531 | + | // above confirmation, matching how the main transcript remains | |
| 532 | + | // visible while --no-yolo waits for a decision. | |
| 533 | + | dialog = renderBtwConfirmation(i.cfg.Theme, mainCols, i.btwDialog, i.confirmDialog) | |
| 534 | + | } else { | |
| 535 | + | dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols) | |
| 536 | + | } | |
| 537 | + | case i.btwDialog.Active(): | |
| 538 | + | dialog = i.btwDialog.Render(i.cfg.Theme, mainCols) | |
| 539 | + | case i.skillsDialog.Active(): | |
| 540 | + | dialog = i.skillsDialog.Render(i.cfg.Theme, mainCols) | |
| 541 | + | case i.changelogDialog.Active(): | |
| 542 | + | dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols) | |
| 543 | + | case i.logoutDialog.Active(): | |
| 544 | + | dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols) | |
| 545 | + | case i.telegramDialog.Active(): | |
| 546 | + | dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols) | |
| 547 | + | case i.settingsDialog.Active(): | |
| 548 | + | dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols) | |
| 549 | + | case i.sessionOpsDialog.Active(): | |
| 550 | + | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols) | |
| 551 | + | case i.sessionTreeDialog.Active(): | |
| 552 | + | // Reserve rows for the editor, status, and tree chrome while using | |
| 553 | + | // the same budget for rendering and PageUp/PageDown movement. | |
| 554 | + | _, rows := i.cfg.Terminal.Size() | |
| 555 | + | avail := rows - 12 | |
| 556 | + | if avail < 3 { | |
| 557 | + | avail = 3 | |
| 558 | + | } | |
| 559 | + | i.sessionTreeDialog.MaxRows = avail | |
| 560 | + | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols) | |
| 561 | + | } | |
| 562 | + | if len(dialog) > 0 { | |
| 563 | + | dialog = padDialogFrame(dialog) | |
| 564 | + | } | |
| 565 | + | modalBackdrop := len(dialog) > 0 | |
| 566 | + | ||
| 567 | + | // Slash-command autocomplete: popup above the status line, only | |
| 568 | + | // when the editor starts with "/" and no dialog is already open. | |
| 569 | + | // Feed extension-registered commands into the suggester first so | |
| 570 | + | // they show up in tab-complete + the popup alongside the built-ins. | |
| 571 | + | i.suggest.SetJailed(i.cfg.Sandbox.Locked()) | |
| 572 | + | i.suggest.SetLlamaConfigured(i.llamaConfigured) | |
| 573 | + | if i.cfg.Extensions != nil { | |
| 574 | + | catalog := i.cfg.Extensions.Commands() | |
| 575 | + | extra := make([]slashCommand, 0, len(catalog)) | |
| 576 | + | for _, c := range catalog { | |
| 577 | + | // The popup renders extension commands under a dedicated | |
| 578 | + | // "── extensions ───" divider, so the description doesn't | |
| 579 | + | // need to repeat the source. If the description is empty, | |
| 580 | + | // fall back to the extension name so the row isn't blank. | |
| 581 | + | desc := c.Description | |
| 582 | + | if strings.TrimSpace(desc) == "" { | |
| 583 | + | desc = "(" + c.Extension + ")" | |
| 584 | + | } | |
| 585 | + | extra = append(extra, slashCommand{ | |
| 586 | + | Name: "/" + c.Name, | |
| 587 | + | Desc: desc, | |
| 588 | + | }) | |
| 589 | + | } | |
| 590 | + | i.suggest.SetExtra(extra) | |
| 591 | + | } | |
| 592 | + | var suggest []string | |
| 593 | + | currentInput := i.ed.Value() | |
| 594 | + | if i.suggest.SkillInputStarted(currentInput) { | |
| 595 | + | var list []*skills.Skill | |
| 596 | + | if i.cfg.SkillSnapshot != nil { | |
| 597 | + | list = i.cfg.SkillSnapshot() | |
| 598 | + | } | |
| 599 | + | i.suggest.SetSkills(list) | |
| 600 | + | } | |
| 601 | + | // Slash popup renders even while the agent is busy so the user | |
| 602 | + | // can queue a destructive command (/clear, /compact, /logout, | |
| 603 | + | // /model) or a read-only one (/help, /jump, /sessions, etc.) | |
| 604 | + | // without waiting for the current turn to finish. The dispatcher | |
| 605 | + | // in runSlash already handles the busy case per-command: safe | |
| 606 | + | // ones run immediately, destructive ones cancel the turn first. | |
| 607 | + | i.fileSuggest.SetCWD(i.cfg.CWD) | |
| 608 | + | mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked()) | |
| 609 | + | if mainInputFocused && i.suggest.Active(currentInput) { | |
| 610 | + | suggest = i.suggest.Render(currentInput, i.cfg.Theme, mainCols) | |
| 611 | + | } else if mainInputFocused && i.fileSuggest.Active(currentInput) { | |
| 612 | + | suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, mainCols) | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | // Detect overlay close (any dialog or slash/file suggestion popup | |
| 616 | + | // just transitioned from open to closed). Force a full redraw so | |
| 617 | + | // the rows the overlay occupied are guaranteed to be repainted | |
| 618 | + | // from the chat below, instead of the diff path leaving stale | |
| 619 | + | // dialog content behind. Equivalent to the user pressing ctrl+l. | |
| 620 | + | overlayOpen := len(dialog) > 0 || len(suggest) > 0 | |
| 621 | + | if i.rend != nil && i.prevOverlayOpen && !overlayOpen { | |
| 622 | + | // An overlay (dialog or slash/file popup) just closed, so the | |
| 623 | + | // bottom band shrinks. On terminals where we can drop | |
| 624 | + | // scrollback, a full Clear is the simplest way to guarantee | |
| 625 | + | // the vacated rows are repainted from the chat below. | |
| 626 | + | // | |
| 627 | + | // On VS Code's terminal closing a dialog leaves the stale | |
| 628 | + | // overlay rows in the retained scrollback (we can't drop them | |
| 629 | + | // with the quiet in-place diff). Run the same full Clear() that | |
| 630 | + | // Ctrl+L uses so the scrollback is purged and the conversation | |
| 631 | + | // is repainted clean, matching what the user expects after | |
| 632 | + | // dismissing a picker. Clear() is keepScrollback-aware and | |
| 633 | + | // emits \x1b[3J there. | |
| 634 | + | i.rend.Clear() | |
| 635 | + | } | |
| 636 | + | i.prevOverlayOpen = overlayOpen | |
| 637 | + | if len(suggest) > 0 { | |
| 638 | + | // One blank row above the popup so it doesn't sit flush | |
| 639 | + | // against the chat / welcome content above. | |
| 640 | + | suggest = append([]string{""}, suggest...) | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | // Busy prefix shown at the far left of the status bar. The spinner owns | |
| 644 | + | // only its frame; the activity label reports the operation in progress. | |
| 645 | + | var busyPrefix string | |
| 646 | + | if i.busy { | |
| 647 | + | label := i.activity.label() | |
| 648 | + | if label == "" { | |
| 649 | + | label = "Preparing request" | |
| 650 | + | } | |
| 651 | + | busyPrefix = fmt.Sprintf("%s %s %s %s", | |
| 652 | + | i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()), | |
| 653 | + | i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, label), | |
| 654 | + | i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"), | |
| 655 | + | i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()), | |
| 656 | + | ) | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | ctxMax := 0 | |
| 660 | + | if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil { | |
| 661 | + | ctxMax = m.ContextWindow | |
| 662 | + | } | |
| 663 | + | statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition) | |
| 664 | + | workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition) | |
| 665 | + | workingWithStatus := statusPosition == workingPosition | |
| 666 | + | statusBusyPrefix := "" | |
| 667 | + | if workingWithStatus { | |
| 668 | + | statusBusyPrefix = busyPrefix | |
| 669 | + | } | |
| 670 | + | goalStatus := string(i.goalStatus) | |
| 671 | + | statusLines := tui.StatusBar(tui.StatusBarParams{ | |
| 672 | + | Theme: i.cfg.Theme, | |
| 673 | + | Provider: i.cfg.Provider, | |
| 674 | + | Model: i.cfg.Model, | |
| 675 | + | Reasoning: i.cfg.Reasoning, | |
| 676 | + | FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode, | |
| 677 | + | Busy: i.busy, | |
| 678 | + | BusyPrefix: statusBusyPrefix, | |
| 679 | + | CWD: i.cfg.CWD, | |
| 680 | + | Locked: i.cfg.Sandbox.Locked(), | |
| 681 | + | NoYolo: i.cfg.NoYolo, | |
| 682 | + | GoalStatus: goalStatus, | |
| 683 | + | Usage: i.cumUsage, | |
| 684 | + | Subscription: i.cfg.AuthMethod == "oauth", | |
| 685 | + | ContextUsed: i.lastCtxInput, | |
| 686 | + | ContextMax: ctxMax, | |
| 687 | + | AutoCompacting: i.autoCompacting, | |
| 688 | + | Telegram: i.telegramBridge != nil && i.telegramBridge.Active(), | |
| 689 | + | Cols: mainCols, | |
| 690 | + | }) | |
| 691 | + | inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) | |
| 692 | + | if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock { | |
| 693 | + | i.ed.Prompt = "" | |
| 694 | + | } else { | |
| 695 | + | i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) | |
| 696 | + | } | |
| 697 | + | edLines, curR, curC := i.ed.Render(mainCols) | |
| 698 | + | dashboardActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil | |
| 699 | + | var allResidentSubagentLines []string | |
| 700 | + | residentSubagentHidden := 0 | |
| 701 | + | if !dashboardActive && i.cfg.ResidentManager != nil { | |
| 702 | + | const residentIndicatorSnapshotLimit = 8 | |
| 703 | + | snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit) | |
| 704 | + | allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock()) | |
| 705 | + | residentSubagentHidden = total - len(snapshots) | |
| 706 | + | } | |
| 707 | + | var workingLines []string | |
| 708 | + | if busyPrefix != "" && !workingWithStatus { | |
| 709 | + | workingLines = []string{" " + busyPrefix} | |
| 710 | + | } | |
| 711 | + | inputCursorOffset := 0 | |
| 712 | + | inputCursorColOffset := 0 | |
| 713 | + | switch inputStyle { | |
| 714 | + | case tui.InputStyleLines: | |
| 715 | + | edLines = tui.InputLines(i.cfg.Theme, edLines, mainCols) | |
| 716 | + | inputCursorOffset = 1 | |
| 717 | + | case tui.InputStyleBlock: | |
| 718 | + | edLines = tui.InputBlock(i.cfg.Theme, edLines, mainCols) | |
| 719 | + | inputCursorOffset = 1 | |
| 720 | + | inputCursorColOffset = 2 | |
| 721 | + | } | |
| 722 | + | ||
| 723 | + | // "Sliding in" chips for messages the user typed while a turn is | |
| 724 | + | // in flight. Shown directly above the status bar so they're close | |
| 725 | + | // to the editor but don't push the chat around. | |
| 726 | + | var queue []string | |
| 727 | + | queued := append([]string(nil), i.queued...) | |
| 728 | + | if i.agent != nil { | |
| 729 | + | queued = append(queued, i.agent.PendingQueuedMessages()...) | |
| 730 | + | } | |
| 731 | + | if len(queued) > 0 { | |
| 732 | + | queue = append(queue, "") | |
| 733 | + | for _, q := range queued { | |
| 734 | + | label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ") | |
| 735 | + | text := truncateLine(q, mainCols-17) | |
| 736 | + | queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)) | |
| 737 | + | } | |
| 738 | + | // Hint row, rendered in the same muted tone as the model | |
| 739 | + | // info on the status bar so it reads as ambient metadata | |
| 740 | + | // rather than a chip. Tells the user how to recover the | |
| 741 | + | // most recent queued message back into the editor. | |
| 742 | + | hint := " Press " + slideBackChordHint() + " to slide back into input" | |
| 743 | + | queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint)) | |
| 744 | + | } | |
| 745 | + | ||
| 746 | + | extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback) | |
| 747 | + | ||
| 748 | + | // Bottom-sticky sections (always visible, never scroll). Each | |
| 749 | + | // non-empty subsection (dialog, suggest popup, sliding-in queue) | |
| 750 | + | // is preceded by one blank row so it has air above the chat | |
| 751 | + | // content. The status block and editor get their own dedicated | |
| 752 | + | // blanks so spacing stays consistent whether or not a dialog or | |
| 753 | + | // popup is showing. | |
| 754 | + | composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) { | |
| 755 | + | bottom = make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9) | |
| 756 | + | inputStartRow = -1 | |
| 757 | + | if len(dialog) > 0 { | |
| 758 | + | bottom = append(bottom, "") | |
| 759 | + | } | |
| 760 | + | bottom = append(bottom, dialog...) | |
| 761 | + | // The resident child views own the bottom of the screen while active. | |
| 762 | + | if dashboardActive { | |
| 763 | + | return bottom, inputStartRow | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | bottom = append(bottom, suggest...) | |
| 767 | + | bottom = append(bottom, queue...) | |
| 768 | + | lineInput := inputStyle == tui.InputStyleLines | |
| 769 | + | statusBelow := statusPosition == tui.StatusPositionBelowInput | |
| 770 | + | workingBelow := workingPosition == tui.WorkingPositionBelowInput | |
| 771 | + | var aboveInput []string | |
| 772 | + | aboveInput = append(aboveInput, extensionLines...) | |
| 773 | + | if !statusBelow { | |
| 774 | + | aboveInput = append(aboveInput, statusLines...) | |
| 775 | + | } | |
| 776 | + | if !workingBelow { | |
| 777 | + | aboveInput = append(aboveInput, workingLines...) | |
| 778 | + | } | |
| 779 | + | var belowInput []string | |
| 780 | + | // Background resident work belongs directly below the editor. It is | |
| 781 | + | // deliberately independent of the main turn's busy/status placement. | |
| 782 | + | belowInput = append(belowInput, residentSubagentLines...) | |
| 783 | + | if workingBelow { | |
| 784 | + | belowInput = append(belowInput, workingLines...) | |
| 785 | + | } | |
| 786 | + | if statusBelow { | |
| 787 | + | belowInput = append(belowInput, statusLines...) | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | bottom = append(bottom, "") | |
| 791 | + | bottom = append(bottom, aboveInput...) | |
| 792 | + | needsGapAboveInput := len(aboveInput) > 0 && !lineInput | |
| 793 | + | if lineInput && statusBelow && !workingBelow && len(workingLines) > 0 { | |
| 794 | + | needsGapAboveInput = true | |
| 795 | + | } | |
| 796 | + | if needsGapAboveInput { | |
| 797 | + | bottom = append(bottom, "") | |
| 798 | + | } | |
| 799 | + | inputStartRow = len(bottom) | |
| 800 | + | bottom = append(bottom, edLines...) | |
| 801 | + | if len(belowInput) > 0 { | |
| 802 | + | // Active resident work sits immediately below the prompt. Other | |
| 803 | + | // below-input chrome keeps the usual breathing room. | |
| 804 | + | if !lineInput && len(residentSubagentLines) == 0 { | |
| 805 | + | bottom = append(bottom, "") | |
| 806 | + | } | |
| 807 | + | bottom = append(bottom, belowInput...) | |
| 808 | + | } | |
| 809 | + | return bottom, inputStartRow | |
| 810 | + | } | |
| 811 | + | ||
| 812 | + | const rendererBottomMarginRows = 1 | |
| 813 | + | maxBottomRows := rows - rendererBottomMarginRows - 1 | |
| 814 | + | var residentSubagentLines []string | |
| 815 | + | var bottom []string | |
| 816 | + | var inputStartRow int | |
| 817 | + | if !dashboardActive && len(allResidentSubagentLines) > 0 { | |
| 818 | + | for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- { | |
| 819 | + | candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols) | |
| 820 | + | candidateBottom, _ := composeBottom(candidate) | |
| 821 | + | if len(candidateBottom) <= maxBottomRows || maxRows == 0 { | |
| 822 | + | residentSubagentLines = candidate | |
| 823 | + | break | |
| 824 | + | } | |
| 825 | + | } | |
| 826 | + | } | |
| 827 | + | bottom, inputStartRow = composeBottom(residentSubagentLines) | |
| 828 | + | ||
| 829 | + | chatRows := rows - len(bottom) | |
| 830 | + | if chatRows < 1 { | |
| 831 | + | chatRows = 1 | |
| 832 | + | } | |
| 833 | + | ||
| 834 | + | // Auto-follow guard: when the user has scrolled up (scrollOffset | |
| 835 | + | // > 0) and the agent appends new content below the viewport while | |
| 836 | + | // they're reading, compensate so the visible content stays | |
| 837 | + | // anchored. scrollOffset is measured from the bottom of `chat`, | |
| 838 | + | // so without compensation a growing buffer pushes the window | |
| 839 | + | // downward through the content and the lines the user was | |
| 840 | + | // reading scroll off the top. | |
| 841 | + | // | |
| 842 | + | // Skip compensation when the terminal width changed (a resize | |
| 843 | + | // reflows the whole buffer and the line-count delta no longer | |
| 844 | + | // corresponds to appended content) and when scrollOffset is 0 | |
| 845 | + | // (the user is following the tail and wants new content to push | |
| 846 | + | // the view down as usual). | |
| 847 | + | // | |
| 848 | + | // The window the user sees starts at row | |
| 849 | + | // start = len(chat) - scrollOffset - chatRows | |
| 850 | + | // so to keep `start` fixed across a redraw we must offset by both | |
| 851 | + | // the buffer growth (len delta) AND the viewport-height change | |
| 852 | + | // (chatRows delta, e.g. the status band or sliding-in queue | |
| 853 | + | // appearing during a turn). Compensating only for the len delta | |
| 854 | + | // let a shrinking chatRows pull the window toward the tail, which | |
| 855 | + | // read as the viewport jumping to the bottom whenever the agent | |
| 856 | + | // streamed text or a tool call grew the bottom band. | |
| 857 | + | if i.scrollOffset > 0 && i.prevChatCols == mainCols && i.prevChatLen > 0 { | |
| 858 | + | i.scrollOffset = anchorScrollOffset(i.scrollOffset, | |
| 859 | + | i.prevChatLen, len(chat), i.prevChatRows, chatRows) | |
| 860 | + | } | |
| 861 | + | i.prevChatLen = len(chat) | |
| 862 | + | i.prevChatCols = mainCols | |
| 863 | + | i.prevChatRows = chatRows | |
| 864 | + | ||
| 865 | + | // Apply scroll offset to the chat slice. | |
| 866 | + | maxOffset := len(chat) - chatRows | |
| 867 | + | if maxOffset < 0 { | |
| 868 | + | maxOffset = 0 | |
| 869 | + | } | |
| 870 | + | // Tail-render expansion: if the user has scrolled to (or above) | |
| 871 | + | // the top of the currently rendered tail and there are still | |
| 872 | + | // truncated messages above, widen view.TailLimit and rebuild. | |
| 873 | + | // The chat cache is keyed on tailLimit so the next cachedChatLocked | |
| 874 | + | // will re-issue Build instead of returning the stale slice. We | |
| 875 | + | // rebuild immediately so the same redraw shows the freshly-revealed | |
| 876 | + | // rows; otherwise the user would have to scroll again to see them. | |
| 877 | + | if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) { | |
| 878 | + | prevLen := len(chat) | |
| 879 | + | i.view.TailLimit += resumeTailExpandStep | |
| 880 | + | if i.view.TailLimit >= len(i.view.Messages) { | |
| 881 | + | i.view.TailLimit = 0 // unbounded | |
| 882 | + | } | |
| 883 | + | i.chatCacheValid = false | |
| 884 | + | chat = i.cachedChatLocked(mainCols) | |
| 885 | + | for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" { | |
| 886 | + | chat = chat[:len(chat)-1] | |
| 887 | + | } | |
| 888 | + | // Newly-revealed rows are older messages prepended at the top. | |
| 889 | + | // scrollOffset counts rows from the bottom, so to keep the | |
| 890 | + | // viewport visually anchored on the same content the user was | |
| 891 | + | // looking at we shift it up by the number of rows added. Keep | |
| 892 | + | // the auto-follow baseline (prevChatLen) in sync with the | |
| 893 | + | // post-expansion length too, so the next render's follow guard | |
| 894 | + | // doesn't see this growth as a synthetic delta and yank the | |
| 895 | + | // viewport again. | |
| 896 | + | if grew := len(chat) - prevLen; grew > 0 { | |
| 897 | + | i.scrollOffset += grew | |
| 898 | + | } | |
| 899 | + | i.prevChatLen = len(chat) | |
| 900 | + | i.prevChatCols = mainCols | |
| 901 | + | maxOffset = len(chat) - chatRows | |
| 902 | + | if maxOffset < 0 { | |
| 903 | + | maxOffset = 0 | |
| 904 | + | } | |
| 905 | + | } | |
| 906 | + | if i.scrollOffset > maxOffset { | |
| 907 | + | i.scrollOffset = maxOffset | |
| 908 | + | } | |
| 909 | + | if i.scrollOffset < 0 { | |
| 910 | + | i.scrollOffset = 0 | |
| 911 | + | } | |
| 912 | + | ||
| 913 | + | var visibleChat []string | |
| 914 | + | if len(chat) <= chatRows { | |
| 915 | + | visibleChat = chat | |
| 916 | + | } else { | |
| 917 | + | end := len(chat) - i.scrollOffset | |
| 918 | + | rawStart := end - chatRows | |
| 919 | + | if rawStart < 0 { | |
| 920 | + | rawStart = 0 | |
| 921 | + | } | |
| 922 | + | start := snapViewportStartToImageBlock(chat, rawStart) | |
| 923 | + | // If the snap pulled start upward (an image-block was atomic) while | |
| 924 | + | // the user is scrolling downward, the viewport would sit on the same | |
| 925 | + | // image until the user mashes down past every reserved row. Bump | |
| 926 | + | // scrollOffset past the image so one keypress always clears it. | |
| 927 | + | if start < rawStart && i.scrollOffset < i.prevScrollOffset { | |
| 928 | + | jump := rawStart - start | |
| 929 | + | i.scrollOffset -= jump | |
| 930 | + | if i.scrollOffset < 0 { | |
| 931 | + | i.scrollOffset = 0 | |
| 932 | + | } | |
| 933 | + | end = len(chat) - i.scrollOffset | |
| 934 | + | rawStart = end - chatRows | |
| 935 | + | if rawStart < 0 { | |
| 936 | + | rawStart = 0 | |
| 937 | + | } | |
| 938 | + | start = snapViewportStartToImageBlock(chat, rawStart) | |
| 939 | + | } | |
| 940 | + | end = start + chatRows | |
| 941 | + | if end > len(chat) { | |
| 942 | + | end = len(chat) | |
| 943 | + | start = end - chatRows | |
| 944 | + | if start < 0 { | |
| 945 | + | start = 0 | |
| 946 | + | } | |
| 947 | + | } | |
| 948 | + | visibleChat = chat[start:end] | |
| 949 | + | } | |
| 950 | + | i.prevScrollOffset = i.scrollOffset | |
| 951 | + | visibleChat = clipBottomClippedImages(visibleChat) | |
| 952 | + | ||
| 953 | + | // A tiny "scrolled up" indicator in the top-right of the chat pane | |
| 954 | + | // so you know you're not at the bottom. When the viewport was | |
| 955 | + | // parked by /jump we include the turn number so the user remembers | |
| 956 | + | // they're reading history rather than the live conversation. | |
| 957 | + | if i.scrollOffset > 0 && len(visibleChat) > 0 { | |
| 958 | + | var text string | |
| 959 | + | if i.parkedTurn > 0 && i.parkedTotal > 0 { | |
| 960 | + | text = fmt.Sprintf(" ↑ viewing turn %d of %d - %d lines more below (pgdn / end)", | |
| 961 | + | i.parkedTurn, i.parkedTotal, i.scrollOffset) | |
| 962 | + | } else { | |
| 963 | + | text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset) | |
| 964 | + | } | |
| 965 | + | note := i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text) | |
| 966 | + | visibleChat = append([]string{note}, visibleChat...) | |
| 967 | + | if len(visibleChat) > chatRows { | |
| 968 | + | visibleChat = visibleChat[:chatRows] | |
| 969 | + | } | |
| 970 | + | } | |
| 971 | + | ||
| 972 | + | // A dialog is the foreground layer. Dim every other visible surface, | |
| 973 | + | // including the main input beneath it, without changing the dialog's | |
| 974 | + | // own styling. Keep the source slices untouched because chat is cached. | |
| 975 | + | if modalBackdrop { | |
| 976 | + | chat = tui.DimLines(chat) | |
| 977 | + | visibleChat = tui.DimLines(visibleChat) | |
| 978 | + | dimmedBottom := tui.DimLines(bottom) | |
| 979 | + | copy(dimmedBottom[1:1+len(dialog)], dialog) | |
| 980 | + | bottom = dimmedBottom | |
| 981 | + | } | |
| 982 | + | ||
| 983 | + | // Default: the real terminal cursor sits on the main editor's | |
| 984 | + | // input position. In main-screen log mode cursor rows are relative | |
| 985 | + | // to the fixed bottom band, not the chat transcript. | |
| 986 | + | // dialogLead is 1 when the bottom region prepends a blank above | |
| 987 | + | // the dialog block (whenever a dialog is showing) so popup-side | |
| 988 | + | // cursor positions still land in the right cell. | |
| 989 | + | dialogLead := 0 | |
| 990 | + | if len(dialog) > 0 { | |
| 991 | + | dialogLead = 1 | |
| 992 | + | } | |
| 993 | + | cursorRow := inputStartRow + inputCursorOffset + curR | |
| 994 | + | cursorCol := curC + inputCursorColOffset | |
| 995 | + | cursorInDialog := false | |
| 996 | + | if i.btwDialog.Active() { | |
| 997 | + | if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 { | |
| 998 | + | cursorRow = dialogLead + r | |
| 999 | + | cursorCol = c | |
| 1000 | + | cursorInDialog = true | |
| 1001 | + | } | |
| 1002 | + | } | |
| 1003 | + | if i.dialog.Active() { | |
| 1004 | + | if r, c := i.dialog.CursorPos(mainCols); r >= 0 { | |
| 1005 | + | cursorRow = dialogLead + r | |
| 1006 | + | cursorCol = c | |
| 1007 | + | cursorInDialog = true | |
| 1008 | + | } | |
| 1009 | + | } | |
| 1010 | + | if i.llamaDialog.Active() { | |
| 1011 | + | if r, c := i.llamaDialog.CursorPos(); r >= 0 { | |
| 1012 | + | cursorRow = dialogLead + r | |
| 1013 | + | cursorCol = c | |
| 1014 | + | cursorInDialog = true | |
| 1015 | + | } else { | |
| 1016 | + | cursorRow = -1 | |
| 1017 | + | cursorCol = 0 | |
| 1018 | + | } | |
| 1019 | + | } | |
| 1020 | + | if i.sessionDialog.Active() { | |
| 1021 | + | if r, c := i.sessionDialog.CursorPos(); r >= 0 { | |
| 1022 | + | cursorRow = dialogLead + r | |
| 1023 | + | cursorCol = c | |
| 1024 | + | cursorInDialog = true | |
| 1025 | + | } | |
| 1026 | + | } | |
| 1027 | + | if i.sessionTreeDialog.Active() { | |
| 1028 | + | if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 { | |
| 1029 | + | cursorRow = dialogLead + r | |
| 1030 | + | cursorCol = c | |
| 1031 | + | cursorInDialog = true | |
| 1032 | + | } | |
| 1033 | + | } | |
| 1034 | + | if i.extPanel.Active() { | |
| 1035 | + | cursorRow = -1 | |
| 1036 | + | cursorCol = 0 | |
| 1037 | + | } | |
| 1038 | + | i.setInputCursorDimmed(modalBackdrop && !cursorInDialog) | |
| 1039 | + | if i.renderRevision.Load() != renderRevision { | |
| 1040 | + | i.mu.Unlock() | |
| 1041 | + | return | |
| 1042 | + | } | |
| 1043 | + | theme := i.cfg.Theme | |
| 1044 | + | // State assembly above is synchronized; terminal output is owned by | |
| 1045 | + | // this renderer goroutine and must not hold the global state mutex. | |
| 1046 | + | i.mu.Unlock() | |
| 1047 | + | if rightBarActive { | |
| 1048 | + | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | |
| 1049 | + | if modalBackdrop { | |
| 1050 | + | i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol) | |
| 1051 | + | } else { | |
| 1052 | + | i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol) | |
| 1053 | + | } | |
| 1054 | + | } else { | |
| 1055 | + | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | |
| 1056 | + | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |
| 1057 | + | } | |
| 1058 | + | i.mu.Lock() | |
| 1059 | + | if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 { | |
| 1060 | + | alert := *i.pendingAlert | |
| 1061 | + | i.pendingAlert = nil | |
| 1062 | + | i.emitAlertLocked(alert) | |
| 1063 | + | } | |
| 1064 | + | i.mu.Unlock() | |
| 1065 | + | } | |
| 1066 | + | func hasImageEscape(line string) bool { | |
| 1067 | + | return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") | |
| 1068 | + | } | |
| 1069 | + | func snapViewportStartToImageBlock(chat []string, start int) int { | |
| 1070 | + | if start <= 0 || start >= len(chat) { | |
| 1071 | + | return start | |
| 1072 | + | } | |
| 1073 | + | if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) { | |
| 1074 | + | return start | |
| 1075 | + | } | |
| 1076 | + | for k := start - 1; k >= 0; k-- { | |
| 1077 | + | line := chat[k] | |
| 1078 | + | if hasImageEscape(line) { | |
| 1079 | + | return k | |
| 1080 | + | } | |
| 1081 | + | if !isBoxBlankLine(line) { | |
| 1082 | + | break | |
| 1083 | + | } | |
| 1084 | + | } | |
| 1085 | + | return start | |
| 1086 | + | } | |
| 1087 | + | func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message { | |
| 1088 | + | if len(msgs) == 0 { | |
| 1089 | + | return nil | |
| 1090 | + | } | |
| 1091 | + | out := make([]provider.Message, 0, len(msgs)) | |
| 1092 | + | for _, m := range msgs { | |
| 1093 | + | if isHiddenTranscriptMessage(m) { | |
| 1094 | + | continue | |
| 1095 | + | } | |
| 1096 | + | out = append(out, m) | |
| 1097 | + | } | |
| 1098 | + | return out | |
| 1099 | + | } | |
| 1100 | + | func isHiddenTranscriptMessage(m provider.Message) bool { | |
| 1101 | + | if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" { | |
| 1102 | + | return true | |
| 1103 | + | } | |
| 1104 | + | if m.Role != provider.RoleUser || len(m.Content) == 0 { | |
| 1105 | + | return false | |
| 1106 | + | } | |
| 1107 | + | tb, ok := m.Content[0].(provider.TextBlock) | |
| 1108 | + | if !ok { | |
| 1109 | + | return false | |
| 1110 | + | } | |
| 1111 | + | return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix | |
| 1112 | + | } | |
| 1113 | + | func clipBottomClippedImages(lines []string) []string { | |
| 1114 | + | if len(lines) == 0 { | |
| 1115 | + | return lines | |
| 1116 | + | } | |
| 1117 | + | out := append([]string(nil), lines...) | |
| 1118 | + | for i, line := range out { | |
| 1119 | + | if !hasImageEscape(line) { | |
| 1120 | + | continue | |
| 1121 | + | } | |
| 1122 | + | // Image blocks render as: image escape, zero or more blank | |
| 1123 | + | // reservation rows, then the muted "image - ..." info line, | |
| 1124 | + | // then one trailing blank. If the info line isn't visible in | |
| 1125 | + | // the current chat slice, the image would paint down into the | |
| 1126 | + | // fixed status bar area. Suppress that image for this frame. | |
| 1127 | + | // | |
| 1128 | + | // When the image lives inside a tool box, the reservation rows | |
| 1129 | + | // are wrapped in vertical box edges ("│ ... │"); those rows | |
| 1130 | + | // look non-blank under a naive whitespace check but are still | |
| 1131 | + | // reservation rows for this scan, so treat them as blank. | |
| 1132 | + | foundInfo := false | |
| 1133 | + | for j := i + 1; j < len(out); j++ { | |
| 1134 | + | if strings.Contains(out[j], "image - ") { | |
| 1135 | + | foundInfo = true | |
| 1136 | + | break | |
| 1137 | + | } | |
| 1138 | + | if !isBoxBlankLine(out[j]) { | |
| 1139 | + | break | |
| 1140 | + | } | |
| 1141 | + | } | |
| 1142 | + | if !foundInfo { | |
| 1143 | + | out[i] = "" | |
| 1144 | + | } | |
| 1145 | + | } | |
| 1146 | + | return out | |
| 1147 | + | } | |
| 1148 | + | func isBoxBlankLine(line string) bool { | |
| 1149 | + | stripped := stripANSIBytes(line) | |
| 1150 | + | stripped = strings.TrimSpace(stripped) | |
| 1151 | + | stripped = strings.Trim(stripped, "│") | |
| 1152 | + | stripped = strings.TrimSpace(stripped) | |
| 1153 | + | return stripped == "" | |
| 1154 | + | } | |
| 1155 | + | func stripANSIBytes(s string) string { | |
| 1156 | + | if !strings.Contains(s, "\x1b") { | |
| 1157 | + | return s | |
| 1158 | + | } | |
| 1159 | + | var b strings.Builder | |
| 1160 | + | b.Grow(len(s)) | |
| 1161 | + | i := 0 | |
| 1162 | + | for i < len(s) { | |
| 1163 | + | if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' { | |
| 1164 | + | end := i + 2 | |
| 1165 | + | for end < len(s) { | |
| 1166 | + | c := s[end] | |
| 1167 | + | end++ | |
| 1168 | + | if c >= 0x40 && c <= 0x7e { | |
| 1169 | + | break | |
| 1170 | + | } | |
| 1171 | + | } | |
| 1172 | + | i = end | |
| 1173 | + | continue | |
| 1174 | + | } | |
| 1175 | + | b.WriteByte(s[i]) | |
| 1176 | + | i++ | |
| 1177 | + | } | |
| 1178 | + | return b.String() | |
| 1179 | + | } | |
| 1180 | + | func panelKeyName(k tui.Key) string { | |
| 1181 | + | switch k.Kind { | |
| 1182 | + | case tui.KeyUp: | |
| 1183 | + | return "up" | |
| 1184 | + | case tui.KeyDown: | |
| 1185 | + | return "down" | |
| 1186 | + | case tui.KeyLeft: | |
| 1187 | + | return "left" | |
| 1188 | + | case tui.KeyRight: | |
| 1189 | + | return "right" | |
| 1190 | + | case tui.KeyEnter: | |
| 1191 | + | return "enter" | |
| 1192 | + | case tui.KeyEsc: | |
| 1193 | + | return "esc" | |
| 1194 | + | case tui.KeyTab: | |
| 1195 | + | return "tab" | |
| 1196 | + | case tui.KeyBackspace: | |
| 1197 | + | return "backspace" | |
| 1198 | + | case tui.KeyDelete: | |
| 1199 | + | return "delete" | |
| 1200 | + | case tui.KeyHome: | |
| 1201 | + | return "home" | |
| 1202 | + | case tui.KeyEnd: | |
| 1203 | + | return "end" | |
| 1204 | + | case tui.KeyPageUp: | |
| 1205 | + | return "pageup" | |
| 1206 | + | case tui.KeyPageDown: | |
| 1207 | + | return "pagedown" | |
| 1208 | + | case tui.KeyRune: | |
| 1209 | + | return "rune" | |
| 1210 | + | default: | |
| 1211 | + | return "unknown" | |
| 1212 | + | } | |
| 1213 | + | } | |
| 1214 | + | func panelKeyText(k tui.Key) string { | |
| 1215 | + | if k.Kind == tui.KeyRune { | |
| 1216 | + | return string(k.Rune) | |
| 1217 | + | } | |
| 1218 | + | return "" | |
| 1219 | + | } | |
| 1220 | + | func truncateLine(s string, n int) string { | |
| 1221 | + | if n <= 0 { | |
| 1222 | + | return "" | |
| 1223 | + | } | |
| 1224 | + | // Collapse newlines — chips are single line. | |
| 1225 | + | s = strings.ReplaceAll(s, "\n", " ↩ ") | |
| 1226 | + | runes := []rune(s) | |
| 1227 | + | if len(runes) <= n { | |
| 1228 | + | return s | |
| 1229 | + | } | |
| 1230 | + | if n <= 3 { | |
| 1231 | + | return strings.Repeat(".", n) | |
| 1232 | + | } | |
| 1233 | + | return string(runes[:n-3]) + "..." | |
| 1234 | + | } | |
A packages/agent/modes/interactive_resident_subagents.go +1107 -0
| @@ -0,0 +1,1107 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/zut/packages/agent/internal/orchestration" | |
| 12 | + | "github.com/bnema/zut/packages/agent/modes/telegram" | |
| 13 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 14 | + | "github.com/bnema/zut/packages/agent/tools" | |
| 15 | + | "github.com/bnema/zut/packages/core" | |
| 16 | + | "github.com/bnema/zut/packages/provider" | |
| 17 | + | "github.com/bnema/zut/packages/tui" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func (i *Interactive) TrackResidentSubagent(childID string) { | |
| 21 | + | if i == nil { | |
| 22 | + | return | |
| 23 | + | } | |
| 24 | + | i.ensureCompletionTracker().TrackResident() | |
| 25 | + | i.registerCoordinatorWorker(childID) | |
| 26 | + | i.invalidate() | |
| 27 | + | i.requestCompletionDelivery() | |
| 28 | + | } | |
| 29 | + | func (i *Interactive) ReportResidentSubagent(completion subagents.ResidentCompletion) { | |
| 30 | + | if i == nil { | |
| 31 | + | return | |
| 32 | + | } | |
| 33 | + | status, errText := "completed", "" | |
| 34 | + | if completion.Err != nil { | |
| 35 | + | status, errText = "failed", completion.Err.Error() | |
| 36 | + | } | |
| 37 | + | i.ensureCompletionTracker().Report(subagents.Completion{AgentID: completion.ChildID, Status: status, Task: completion.Task, Error: errText, Summary: completion.Summary}) | |
| 38 | + | i.reloadOpenResidentChildSession(completion.ChildID) | |
| 39 | + | i.invalidate() | |
| 40 | + | i.requestCompletionDelivery() | |
| 41 | + | } | |
| 42 | + | func (i *Interactive) reloadOpenResidentChildSession(childID string) { | |
| 43 | + | if i == nil { | |
| 44 | + | return | |
| 45 | + | } | |
| 46 | + | i.mu.Lock() | |
| 47 | + | session := i.residentChildSession | |
| 48 | + | i.mu.Unlock() | |
| 49 | + | if session == nil || session.childID != childID || !session.RequestRecentReload() { | |
| 50 | + | return | |
| 51 | + | } | |
| 52 | + | i.reloadResidentChildSession(session) | |
| 53 | + | } | |
| 54 | + | func (i *Interactive) reloadResidentChildSession(session *residentChildSession) { | |
| 55 | + | if i == nil || session == nil { | |
| 56 | + | return | |
| 57 | + | } | |
| 58 | + | go func() { | |
| 59 | + | err := session.ReloadRecent(200) | |
| 60 | + | if session.FinishLoad(err) { | |
| 61 | + | if session.RequestRecentReload() { | |
| 62 | + | i.reloadResidentChildSession(session) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | i.invalidate() | |
| 66 | + | }() | |
| 67 | + | } | |
| 68 | + | func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker { | |
| 69 | + | // Tests and lightweight embedders may construct Interactive with a struct | |
| 70 | + | // literal instead of NewInteractive, so retain this lazy fallback. | |
| 71 | + | i.completionDeliveryMu.Lock() | |
| 72 | + | defer i.completionDeliveryMu.Unlock() | |
| 73 | + | if i.completionTracker == nil { | |
| 74 | + | i.completionTracker = subagents.NewCompletionTracker() | |
| 75 | + | } | |
| 76 | + | return i.completionTracker | |
| 77 | + | } | |
| 78 | + | func (i *Interactive) ensureCoordinatorLocked() *orchestration.Coordinator { | |
| 79 | + | if i.turnCoordinator == nil { | |
| 80 | + | i.turnCoordinator = orchestration.New() | |
| 81 | + | } | |
| 82 | + | return i.turnCoordinator | |
| 83 | + | } | |
| 84 | + | func (i *Interactive) coordinatorAcceptsUserInput() bool { | |
| 85 | + | if i == nil { | |
| 86 | + | return false | |
| 87 | + | } | |
| 88 | + | i.completionDeliveryMu.Lock() | |
| 89 | + | accepts := i.ensureCoordinatorLocked().AcceptsUserInput() | |
| 90 | + | i.completionDeliveryMu.Unlock() | |
| 91 | + | return accepts | |
| 92 | + | } | |
| 93 | + | func (i *Interactive) coordinatorHasPendingWorkers() bool { | |
| 94 | + | if i == nil { | |
| 95 | + | return false | |
| 96 | + | } | |
| 97 | + | i.completionDeliveryMu.Lock() | |
| 98 | + | pending := i.ensureCoordinatorLocked().HasPendingWorkers() | |
| 99 | + | i.completionDeliveryMu.Unlock() | |
| 100 | + | return pending | |
| 101 | + | } | |
| 102 | + | func (i *Interactive) applyCoordinator(event orchestration.Event) []orchestration.Action { | |
| 103 | + | if i == nil { | |
| 104 | + | return nil | |
| 105 | + | } | |
| 106 | + | i.completionDeliveryMu.Lock() | |
| 107 | + | result := i.ensureCoordinatorLocked().Apply(event) | |
| 108 | + | i.completionDeliveryMu.Unlock() | |
| 109 | + | return result.Actions | |
| 110 | + | } | |
| 111 | + | func (i *Interactive) registerCoordinatorWorker(workerID string) { | |
| 112 | + | if i == nil || workerID == "" { | |
| 113 | + | return | |
| 114 | + | } | |
| 115 | + | i.completionDeliveryMu.Lock() | |
| 116 | + | coordinator := i.ensureCoordinatorLocked() | |
| 117 | + | implicitWave := i.completionDeliveryHolds == 0 | |
| 118 | + | if implicitWave { | |
| 119 | + | coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerStarted}) | |
| 120 | + | } | |
| 121 | + | i.coordinatorWorkerSeq++ | |
| 122 | + | registrationID := fmt.Sprintf("%s#%d", workerID, i.coordinatorWorkerSeq) | |
| 123 | + | if i.coordinatorWorkerIDs == nil { | |
| 124 | + | i.coordinatorWorkerIDs = make(map[string][]string) | |
| 125 | + | } | |
| 126 | + | i.coordinatorWorkerIDs[workerID] = append(i.coordinatorWorkerIDs[workerID], registrationID) | |
| 127 | + | coordinator.Apply(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: registrationID}) | |
| 128 | + | var actions []orchestration.Action | |
| 129 | + | if implicitWave { | |
| 130 | + | actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions | |
| 131 | + | } | |
| 132 | + | i.completionDeliveryMu.Unlock() | |
| 133 | + | i.executeCoordinatorActions(actions) | |
| 134 | + | } | |
| 135 | + | func (i *Interactive) cancelCoordinator() { | |
| 136 | + | if i == nil { | |
| 137 | + | return | |
| 138 | + | } | |
| 139 | + | i.completionDeliveryMu.Lock() | |
| 140 | + | coordinator := i.ensureCoordinatorLocked() | |
| 141 | + | actions := coordinator.Apply(orchestration.Event{Kind: orchestration.EventCancelled}).Actions | |
| 142 | + | // A later user turn starts a fresh wave. Drop resident registrations as | |
| 143 | + | // well as coordinator identities so late outcomes cannot match a new child. | |
| 144 | + | if i.completionTracker != nil { | |
| 145 | + | i.completionTracker.Reset() | |
| 146 | + | } | |
| 147 | + | i.turnCoordinator = orchestration.New() | |
| 148 | + | i.coordinatorWorkerIDs = nil | |
| 149 | + | i.completionDeliveryMu.Unlock() | |
| 150 | + | i.executeCoordinatorActions(actions) | |
| 151 | + | } | |
| 152 | + | func (i *Interactive) takeCoordinatorWorkerID(agentID string) string { | |
| 153 | + | if i == nil || agentID == "" { | |
| 154 | + | return "" | |
| 155 | + | } | |
| 156 | + | i.completionDeliveryMu.Lock() | |
| 157 | + | defer i.completionDeliveryMu.Unlock() | |
| 158 | + | ids := i.coordinatorWorkerIDs[agentID] | |
| 159 | + | if len(ids) == 0 { | |
| 160 | + | return "" | |
| 161 | + | } | |
| 162 | + | workerID := ids[0] | |
| 163 | + | if len(ids) == 1 { | |
| 164 | + | delete(i.coordinatorWorkerIDs, agentID) | |
| 165 | + | } else { | |
| 166 | + | i.coordinatorWorkerIDs[agentID] = ids[1:] | |
| 167 | + | } | |
| 168 | + | return workerID | |
| 169 | + | } | |
| 170 | + | func (i *Interactive) executeCoordinatorActions(actions []orchestration.Action) { | |
| 171 | + | for _, action := range actions { | |
| 172 | + | if action.Kind != orchestration.ActionRunManager { | |
| 173 | + | continue | |
| 174 | + | } | |
| 175 | + | prompt := action.Text | |
| 176 | + | if len(action.Completions) != 0 { | |
| 177 | + | instruction := "Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks." | |
| 178 | + | update := subagents.FormatCompletionUpdate(action.Completions, instruction) | |
| 179 | + | if prompt != "" { | |
| 180 | + | prompt = update + "\n\nQueued user request:\n" + prompt | |
| 181 | + | } else { | |
| 182 | + | prompt = update | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | if prompt != "" { | |
| 186 | + | i.submitOrQueue(prompt, nil, false) | |
| 187 | + | } else if action.Reason == orchestration.WakeGoal { | |
| 188 | + | parent := i.runCtx | |
| 189 | + | if parent == nil { | |
| 190 | + | parent = context.Background() | |
| 191 | + | } | |
| 192 | + | i.requestGoalContinuationIfIdle(parent) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | func (i *Interactive) requestCompletionDelivery() { | |
| 197 | + | if i == nil { | |
| 198 | + | return | |
| 199 | + | } | |
| 200 | + | i.ensureCompletionTracker() | |
| 201 | + | i.completionDeliveryMu.Lock() | |
| 202 | + | i.completionDeliveryRequest = true | |
| 203 | + | if i.completionDeliveryRunning || i.completionDeliveryHolds != 0 { | |
| 204 | + | i.completionDeliveryMu.Unlock() | |
| 205 | + | return | |
| 206 | + | } | |
| 207 | + | i.completionDeliveryRunning = true | |
| 208 | + | i.completionDeliveryMu.Unlock() | |
| 209 | + | ||
| 210 | + | go i.deliverCompletionUpdates() | |
| 211 | + | } | |
| 212 | + | func (i *Interactive) beginCompletionDeliveryHold() func() { | |
| 213 | + | if i == nil { | |
| 214 | + | return func() {} | |
| 215 | + | } | |
| 216 | + | i.completionDeliveryMu.Lock() | |
| 217 | + | i.ensureCoordinatorLocked().Apply(orchestration.Event{Kind: orchestration.EventManagerStarted}) | |
| 218 | + | i.completionDeliveryHolds++ | |
| 219 | + | i.completionDeliveryMu.Unlock() | |
| 220 | + | return i.releaseCompletionDeliveryHold | |
| 221 | + | } | |
| 222 | + | func (i *Interactive) releaseCompletionDeliveryHold() { | |
| 223 | + | if i == nil { | |
| 224 | + | return | |
| 225 | + | } | |
| 226 | + | start := false | |
| 227 | + | var actions []orchestration.Action | |
| 228 | + | i.completionDeliveryMu.Lock() | |
| 229 | + | if i.completionDeliveryHolds > 0 { | |
| 230 | + | i.completionDeliveryHolds-- | |
| 231 | + | } | |
| 232 | + | if i.completionDeliveryHolds == 0 { | |
| 233 | + | // The coordinator must observe the goal before sealing the wave, so a | |
| 234 | + | // pending worker wins the wake decision instead of a direct continuation. | |
| 235 | + | _, goalActive := i.goalContinuationMessage() | |
| 236 | + | coordinator := i.ensureCoordinatorLocked() | |
| 237 | + | coordinator.Apply(orchestration.Event{Kind: orchestration.EventGoalChanged, GoalActive: goalActive}) | |
| 238 | + | actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions | |
| 239 | + | } | |
| 240 | + | if i.completionDeliveryHolds == 0 && i.completionDeliveryRequest && !i.completionDeliveryRunning { | |
| 241 | + | i.completionDeliveryRunning = true | |
| 242 | + | start = true | |
| 243 | + | } | |
| 244 | + | i.completionDeliveryMu.Unlock() | |
| 245 | + | i.executeCoordinatorActions(actions) | |
| 246 | + | if start { | |
| 247 | + | go i.deliverCompletionUpdates() | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | func (i *Interactive) completionWaitContext() context.Context { | |
| 251 | + | i.mu.Lock() | |
| 252 | + | ctx := i.runCtx | |
| 253 | + | i.mu.Unlock() | |
| 254 | + | if ctx == nil { | |
| 255 | + | return context.Background() | |
| 256 | + | } | |
| 257 | + | return ctx | |
| 258 | + | } | |
| 259 | + | func (i *Interactive) deliverCompletionUpdates() { | |
| 260 | + | for { | |
| 261 | + | i.completionDeliveryMu.Lock() | |
| 262 | + | i.completionDeliveryRequest = false | |
| 263 | + | tracker := i.completionTracker | |
| 264 | + | i.completionDeliveryMu.Unlock() | |
| 265 | + | ||
| 266 | + | batch, err := tracker.WaitIdle(i.completionWaitContext()) | |
| 267 | + | if err == nil && len(batch) != 0 { | |
| 268 | + | var actions []orchestration.Action | |
| 269 | + | for _, completion := range batch { | |
| 270 | + | workerID := i.takeCoordinatorWorkerID(completion.AgentID) | |
| 271 | + | if workerID == "" { | |
| 272 | + | continue | |
| 273 | + | } | |
| 274 | + | actions = append(actions, i.applyCoordinator(orchestration.Event{ | |
| 275 | + | Kind: orchestration.EventWorkerFinished, | |
| 276 | + | WorkerID: workerID, | |
| 277 | + | Completion: completion, | |
| 278 | + | })...) | |
| 279 | + | } | |
| 280 | + | i.executeCoordinatorActions(actions) | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | i.completionDeliveryMu.Lock() | |
| 284 | + | if i.completionDeliveryRequest { | |
| 285 | + | i.completionDeliveryMu.Unlock() | |
| 286 | + | continue | |
| 287 | + | } | |
| 288 | + | i.completionDeliveryRunning = false | |
| 289 | + | i.completionDeliveryMu.Unlock() | |
| 290 | + | return | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | func autoSubagentsAddenda(cfg InteractiveConfig, orchestrating bool) []string { | |
| 294 | + | addenda := make([]string, 0, 2) | |
| 295 | + | if cfg.ResidentManager != nil && autoSubagentsToolAllowedConfig(cfg) { | |
| 296 | + | if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" { | |
| 297 | + | addenda = append(addenda, addendum) | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | if !orchestrating { | |
| 301 | + | if cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(cfg) { | |
| 302 | + | if addendum := strings.TrimSpace(cfg.OnDemandSubagentsSystemAddendum); addendum != "" { | |
| 303 | + | addenda = append(addenda, addendum) | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | return addenda | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" { | |
| 310 | + | addenda = append(addenda, addendum) | |
| 311 | + | } | |
| 312 | + | return addenda | |
| 313 | + | } | |
| 314 | + | func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) { | |
| 315 | + | idx := strings.LastIndex(system, addendum) | |
| 316 | + | if idx < 0 { | |
| 317 | + | return system, false | |
| 318 | + | } | |
| 319 | + | return system[:idx] + system[idx+len(addendum):], true | |
| 320 | + | } | |
| 321 | + | func (i *Interactive) applyAutoSubagentsSystemPrompt(orchestrating bool) { | |
| 322 | + | // i.mu is sufficient here: this path updates the existing agent's system | |
| 323 | + | // prompt and managed addenda in place; it does not replace the agent or | |
| 324 | + | // its tool registry, so agentMu is not required. | |
| 325 | + | i.mu.Lock() | |
| 326 | + | defer i.mu.Unlock() | |
| 327 | + | if i.agent == nil { | |
| 328 | + | return | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | sys, _ := i.agent.PromptConfig() | |
| 332 | + | changed := false | |
| 333 | + | if guidance := strings.TrimSpace(i.cfg.WritingGuidance); guidance != "" { | |
| 334 | + | var removed bool | |
| 335 | + | sys, removed = removeLastAutoSubagentsAddendum(sys, guidance) | |
| 336 | + | changed = changed || removed | |
| 337 | + | } | |
| 338 | + | for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- { | |
| 339 | + | var removed bool | |
| 340 | + | sys, removed = removeLastAutoSubagentsAddendum(sys, i.managedAutoSubagentsAddenda[idx]) | |
| 341 | + | changed = changed || removed | |
| 342 | + | } | |
| 343 | + | i.managedAutoSubagentsAddenda = nil | |
| 344 | + | ||
| 345 | + | addenda := autoSubagentsAddenda(i.cfg, orchestrating) | |
| 346 | + | if changed { | |
| 347 | + | sys = strings.TrimRight(sys, "\n") | |
| 348 | + | } | |
| 349 | + | for _, addendum := range addenda { | |
| 350 | + | if sys != "" { | |
| 351 | + | sys += "\n\n" | |
| 352 | + | } | |
| 353 | + | sys += addendum | |
| 354 | + | i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum) | |
| 355 | + | changed = true | |
| 356 | + | } | |
| 357 | + | if guidance := strings.TrimSpace(i.cfg.WritingGuidance); guidance != "" { | |
| 358 | + | if sys != "" { | |
| 359 | + | sys += "\n\n" | |
| 360 | + | } | |
| 361 | + | sys += guidance | |
| 362 | + | changed = true | |
| 363 | + | } | |
| 364 | + | if !changed { | |
| 365 | + | return | |
| 366 | + | } | |
| 367 | + | i.agent.SetSystemPrompt(sys) | |
| 368 | + | } | |
| 369 | + | func (i *Interactive) autoSubagentsAvailable() bool { | |
| 370 | + | return i.cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(i.cfg) | |
| 371 | + | } | |
| 372 | + | func (i *Interactive) autoSubagentsUnavailableHint() string { | |
| 373 | + | var hints []string | |
| 374 | + | if i.cfg.ResidentManager == nil { | |
| 375 | + | hints = append(hints, "resident subagent manager is unavailable in this mode") | |
| 376 | + | } | |
| 377 | + | if !autoSubagentsAnyToolAllowedConfig(i.cfg) { | |
| 378 | + | hints = append(hints, "launch-time tool policy excludes subagent manager tools") | |
| 379 | + | } | |
| 380 | + | return strings.Join(hints, "; ") | |
| 381 | + | } | |
| 382 | + | func autoSubagentsToolAllowedConfig(cfg InteractiveConfig) bool { | |
| 383 | + | return cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed | |
| 384 | + | } | |
| 385 | + | func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool { | |
| 386 | + | if cfg.AutoSubagentsStatusToolAllowed != nil { | |
| 387 | + | return *cfg.AutoSubagentsStatusToolAllowed | |
| 388 | + | } | |
| 389 | + | // Preserve the old single-boolean embedding contract: when callers have | |
| 390 | + | // not supplied the new field, status follows the existing delegation gate. | |
| 391 | + | return autoSubagentsToolAllowedConfig(cfg) | |
| 392 | + | } | |
| 393 | + | func autoSubagentsStopToolAllowedConfig(cfg InteractiveConfig) bool { | |
| 394 | + | if cfg.AutoSubagentsStopToolAllowed != nil { | |
| 395 | + | return *cfg.AutoSubagentsStopToolAllowed | |
| 396 | + | } | |
| 397 | + | return autoSubagentsToolAllowedConfig(cfg) | |
| 398 | + | } | |
| 399 | + | func autoSubagentsResumeToolAllowedConfig(cfg InteractiveConfig) bool { | |
| 400 | + | if cfg.AutoSubagentsResumeToolAllowed != nil { | |
| 401 | + | return *cfg.AutoSubagentsResumeToolAllowed | |
| 402 | + | } | |
| 403 | + | return autoSubagentsToolAllowedConfig(cfg) | |
| 404 | + | } | |
| 405 | + | func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool { | |
| 406 | + | return autoSubagentsToolAllowedConfig(cfg) || | |
| 407 | + | autoSubagentsStatusToolAllowedConfig(cfg) || | |
| 408 | + | autoSubagentsStopToolAllowedConfig(cfg) || | |
| 409 | + | autoSubagentsResumeToolAllowedConfig(cfg) | |
| 410 | + | } | |
| 411 | + | func (i *Interactive) autoSubagentsToolAllowed() bool { | |
| 412 | + | return autoSubagentsToolAllowedConfig(i.cfg) | |
| 413 | + | } | |
| 414 | + | func (i *Interactive) autoSubagentsStatusToolAllowed() bool { | |
| 415 | + | return autoSubagentsStatusToolAllowedConfig(i.cfg) | |
| 416 | + | } | |
| 417 | + | func (i *Interactive) autoSubagentsStopToolAllowed() bool { | |
| 418 | + | return autoSubagentsStopToolAllowedConfig(i.cfg) | |
| 419 | + | } | |
| 420 | + | func (i *Interactive) autoSubagentsResumeToolAllowed() bool { | |
| 421 | + | return autoSubagentsResumeToolAllowedConfig(i.cfg) | |
| 422 | + | } | |
| 423 | + | func (i *Interactive) autoSubagentsEnabledLocked() bool { | |
| 424 | + | return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable() | |
| 425 | + | } | |
| 426 | + | func (i *Interactive) applyAutoSubagentsTool() { | |
| 427 | + | i.agentMu.Lock() | |
| 428 | + | defer i.agentMu.Unlock() | |
| 429 | + | i.mu.Lock() | |
| 430 | + | defer i.mu.Unlock() | |
| 431 | + | if i.agent == nil { | |
| 432 | + | return | |
| 433 | + | } | |
| 434 | + | current := i.agent.ToolsSnapshot() | |
| 435 | + | next := core.Registry{} | |
| 436 | + | for name, t := range current { | |
| 437 | + | if name == "subagent_spawn" || name == "subagent_status" || name == "subagent_stop" || name == "subagent_resume" { | |
| 438 | + | continue | |
| 439 | + | } | |
| 440 | + | next[name] = t | |
| 441 | + | } | |
| 442 | + | if i.autoSubagentsAvailable() { | |
| 443 | + | if i.autoSubagentsToolAllowed() { | |
| 444 | + | canonical := &tools.SubagentSpawnTool{ | |
| 445 | + | ResidentManager: i.cfg.ResidentManager, | |
| 446 | + | BuildResidentSpec: i.cfg.BuildResidentSpec, | |
| 447 | + | Enabled: func() bool { return true }, | |
| 448 | + | DefaultModel: func() string { return i.cfg.Model }, | |
| 449 | + | DefaultProvider: func() string { return i.cfg.Provider }, | |
| 450 | + | DefaultReasoning: func() string { return i.cfg.Reasoning }, | |
| 451 | + | ResolveSubagent: i.cfg.ResolveSubagent, | |
| 452 | + | } | |
| 453 | + | next[canonical.Name()] = canonical | |
| 454 | + | } | |
| 455 | + | if i.autoSubagentsStatusToolAllowed() { | |
| 456 | + | statusTool := &tools.SubagentStatusTool{ | |
| 457 | + | ResidentManager: i.cfg.ResidentManager, | |
| 458 | + | Enabled: func() bool { return true }, | |
| 459 | + | } | |
| 460 | + | next[statusTool.Name()] = statusTool | |
| 461 | + | } | |
| 462 | + | if i.autoSubagentsStopToolAllowed() { | |
| 463 | + | stopTool := &tools.SubagentStopTool{ | |
| 464 | + | ResidentManager: i.cfg.ResidentManager, | |
| 465 | + | Enabled: func() bool { return true }, | |
| 466 | + | } | |
| 467 | + | next[stopTool.Name()] = stopTool | |
| 468 | + | } | |
| 469 | + | if i.autoSubagentsResumeToolAllowed() { | |
| 470 | + | resumeTool := &tools.SubagentResumeTool{ | |
| 471 | + | ResidentManager: i.cfg.ResidentManager, | |
| 472 | + | Enabled: func() bool { return true }, | |
| 473 | + | } | |
| 474 | + | next[resumeTool.Name()] = resumeTool | |
| 475 | + | } | |
| 476 | + | } | |
| 477 | + | i.agent.SetTools(next) | |
| 478 | + | } | |
| 479 | + | func (i *Interactive) applyTelegramTools(active bool) { | |
| 480 | + | if active { | |
| 481 | + | // The child ceiling must be in place even when no live main agent exists, | |
| 482 | + | // and before Bridge.Start can accept an external prompt. | |
| 483 | + | i.setWebSearchAvailable(false) | |
| 484 | + | } | |
| 485 | + | i.agentMu.Lock() | |
| 486 | + | defer i.agentMu.Unlock() | |
| 487 | + | i.mu.Lock() | |
| 488 | + | defer i.mu.Unlock() | |
| 489 | + | if i.agent == nil { | |
| 490 | + | return | |
| 491 | + | } | |
| 492 | + | current := i.agent.ToolsSnapshot() | |
| 493 | + | next := core.Registry{} | |
| 494 | + | for name, t := range current { | |
| 495 | + | if name == "telegram_send_image" || name == "telegram_send_file" { | |
| 496 | + | continue | |
| 497 | + | } | |
| 498 | + | if active && name == "web_search" { | |
| 499 | + | // External Telegram prompts have no per-request confirmation | |
| 500 | + | // surface, so V1 does not expose native web search while paired. | |
| 501 | + | continue | |
| 502 | + | } | |
| 503 | + | next[name] = t | |
| 504 | + | } | |
| 505 | + | if active { | |
| 506 | + | sender := telegramSenderAdapter{bridge: i.telegramBridge} | |
| 507 | + | next["telegram_send_image"] = &tools.TelegramSendImageTool{ | |
| 508 | + | CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender, | |
| 509 | + | } | |
| 510 | + | next["telegram_send_file"] = &tools.TelegramSendFileTool{ | |
| 511 | + | CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender, | |
| 512 | + | } | |
| 513 | + | } | |
| 514 | + | i.agent.SetTools(next) | |
| 515 | + | _, webSearchAvailable := next["web_search"] | |
| 516 | + | i.setWebSearchAvailable(webSearchAvailable && !active) | |
| 517 | + | } | |
| 518 | + | func (i *Interactive) telegramStatus() { | |
| 519 | + | var msg string | |
| 520 | + | if i.telegramBridge != nil && i.telegramBridge.Active() { | |
| 521 | + | s := i.telegramBridge.State() | |
| 522 | + | msg = "telegram: connected (tui bridge)" | |
| 523 | + | if s.Username != "" { | |
| 524 | + | msg += " as @" + s.Username | |
| 525 | + | } | |
| 526 | + | if s.PairedID != 0 { | |
| 527 | + | msg += fmt.Sprintf(" - paired with user %d", s.PairedID) | |
| 528 | + | } else { | |
| 529 | + | msg += " - awaiting pairing" | |
| 530 | + | } | |
| 531 | + | } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 { | |
| 532 | + | msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid) | |
| 533 | + | } else { | |
| 534 | + | cfg, _ := telegram.LoadConfig(i.cfg.ZutHome) | |
| 535 | + | if cfg.BotToken == "" { | |
| 536 | + | msg = "telegram: not configured. run `zut telegram-bot setup` first." | |
| 537 | + | } else { | |
| 538 | + | msg = "telegram: disconnected" | |
| 539 | + | if cfg.BotUsername != "" { | |
| 540 | + | msg += " (@" + cfg.BotUsername + " ready to connect)" | |
| 541 | + | } | |
| 542 | + | } | |
| 543 | + | } | |
| 544 | + | i.mu.Lock() | |
| 545 | + | i.statusOK = msg | |
| 546 | + | i.statusErr = "" | |
| 547 | + | i.mu.Unlock() | |
| 548 | + | i.invalidate() | |
| 549 | + | } | |
| 550 | + | func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) { | |
| 551 | + | h.iv.SubmitOrQueue(prompt, images) | |
| 552 | + | } | |
| 553 | + | func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() } | |
| 554 | + | func (h *telegramHost) Status() string { | |
| 555 | + | h.iv.mu.Lock() | |
| 556 | + | providerName := h.iv.cfg.Provider | |
| 557 | + | model := h.iv.cfg.Model | |
| 558 | + | cwd := h.iv.cfg.CWD | |
| 559 | + | usage := h.iv.cumUsage | |
| 560 | + | subscription := h.iv.cfg.AuthMethod == "oauth" | |
| 561 | + | ctxUsed := h.iv.lastCtxInput | |
| 562 | + | busy := h.iv.busy | |
| 563 | + | queued := len(h.iv.queued) | |
| 564 | + | h.iv.mu.Unlock() | |
| 565 | + | ||
| 566 | + | ctxMax := 0 | |
| 567 | + | if m, err := provider.FindModel(providerName, model); err == nil { | |
| 568 | + | ctxMax = m.ContextWindow | |
| 569 | + | } | |
| 570 | + | return telegram.FormatStatus(telegram.StatusSnapshot{ | |
| 571 | + | Provider: providerName, | |
| 572 | + | Model: model, | |
| 573 | + | CWD: cwd, | |
| 574 | + | Usage: usage, | |
| 575 | + | Subscription: subscription, | |
| 576 | + | ContextUsed: ctxUsed, | |
| 577 | + | ContextMax: ctxMax, | |
| 578 | + | Busy: busy, | |
| 579 | + | Queued: queued, | |
| 580 | + | }) | |
| 581 | + | } | |
| 582 | + | func (h *telegramHost) Notify(level, message string) { | |
| 583 | + | h.iv.mu.Lock() | |
| 584 | + | switch level { | |
| 585 | + | case "error", "warn": | |
| 586 | + | h.iv.statusErr = message | |
| 587 | + | h.iv.statusOK = "" | |
| 588 | + | default: | |
| 589 | + | h.iv.statusOK = message | |
| 590 | + | h.iv.statusErr = "" | |
| 591 | + | } | |
| 592 | + | h.iv.mu.Unlock() | |
| 593 | + | h.iv.invalidate() | |
| 594 | + | } | |
| 595 | + | func (i *Interactive) openSessionOpsDialog() { | |
| 596 | + | items := []sessionOpsItem{ | |
| 597 | + | {label: "export", action: "export", hint: "write the current session to a .zutsession file"}, | |
| 598 | + | {label: "import", action: "import", hint: "load a .zutsession file into this directory"}, | |
| 599 | + | {label: "fork", action: "fork", hint: "branch from a past user message into a new session"}, | |
| 600 | + | {label: "tree", action: "tree", hint: "switch between branches in this directory"}, | |
| 601 | + | } | |
| 602 | + | i.sessionOpsDialog.Open(items) | |
| 603 | + | i.invalidate() | |
| 604 | + | } | |
| 605 | + | func (i *Interactive) doSessionOp(action, arg string) { | |
| 606 | + | switch action { | |
| 607 | + | case "export": | |
| 608 | + | i.doSessionExport(arg) | |
| 609 | + | case "import": | |
| 610 | + | i.doSessionImport(arg) | |
| 611 | + | case "fork": | |
| 612 | + | i.doSessionFork() | |
| 613 | + | case "tree": | |
| 614 | + | i.doSessionTree() | |
| 615 | + | default: | |
| 616 | + | i.mu.Lock() | |
| 617 | + | i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)" | |
| 618 | + | i.mu.Unlock() | |
| 619 | + | i.invalidate() | |
| 620 | + | } | |
| 621 | + | } | |
| 622 | + | func (i *Interactive) doSessionExport(dst string) { | |
| 623 | + | src := i.currentSessionPath() | |
| 624 | + | if src == "" { | |
| 625 | + | i.mu.Lock() | |
| 626 | + | i.statusErr = "export: no session is active (running with --no-session?)" | |
| 627 | + | i.mu.Unlock() | |
| 628 | + | i.invalidate() | |
| 629 | + | return | |
| 630 | + | } | |
| 631 | + | // Persist any in-memory agent messages to the session file so | |
| 632 | + | // the export carries the full conversation. Without this, the | |
| 633 | + | // default lazy-flush-at-exit strategy leaves most of a running | |
| 634 | + | // session unwritten and the export ends up with only the meta. | |
| 635 | + | if i.cfg.FlushSession != nil { | |
| 636 | + | i.cfg.FlushSession() | |
| 637 | + | } | |
| 638 | + | dst = unquotePath(dst) | |
| 639 | + | if dst == "" { | |
| 640 | + | dst = defaultExportDir() | |
| 641 | + | } else { | |
| 642 | + | dst = expandTilde(dst) | |
| 643 | + | } | |
| 644 | + | out, err := core.ExportSession(src, dst) | |
| 645 | + | if err != nil { | |
| 646 | + | i.mu.Lock() | |
| 647 | + | i.statusErr = "export: " + err.Error() | |
| 648 | + | i.mu.Unlock() | |
| 649 | + | i.invalidate() | |
| 650 | + | return | |
| 651 | + | } | |
| 652 | + | i.mu.Lock() | |
| 653 | + | i.statusOK = "exported session to " + friendlyPath(out) | |
| 654 | + | i.statusErr = "" | |
| 655 | + | i.mu.Unlock() | |
| 656 | + | i.invalidate() | |
| 657 | + | } | |
| 658 | + | func (i *Interactive) doSessionImport(src string) { | |
| 659 | + | src = unquotePath(src) | |
| 660 | + | if src == "" { | |
| 661 | + | i.mu.Lock() | |
| 662 | + | i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zutsession" | |
| 663 | + | i.mu.Unlock() | |
| 664 | + | i.invalidate() | |
| 665 | + | return | |
| 666 | + | } | |
| 667 | + | src = expandTilde(src) | |
| 668 | + | if _, err := os.Stat(src); err != nil { | |
| 669 | + | i.mu.Lock() | |
| 670 | + | i.statusErr = "import: " + err.Error() | |
| 671 | + | i.mu.Unlock() | |
| 672 | + | i.invalidate() | |
| 673 | + | return | |
| 674 | + | } | |
| 675 | + | newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version) | |
| 676 | + | if err != nil { | |
| 677 | + | i.mu.Lock() | |
| 678 | + | i.statusErr = "import: " + err.Error() | |
| 679 | + | i.mu.Unlock() | |
| 680 | + | i.invalidate() | |
| 681 | + | return | |
| 682 | + | } | |
| 683 | + | if i.cfg.LoadSession == nil { | |
| 684 | + | i.mu.Lock() | |
| 685 | + | i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)" | |
| 686 | + | i.statusErr = "" | |
| 687 | + | i.mu.Unlock() | |
| 688 | + | i.invalidate() | |
| 689 | + | return | |
| 690 | + | } | |
| 691 | + | i.markSessionTitleSwitching() | |
| 692 | + | if err := i.cfg.LoadSession(newPath); err != nil { | |
| 693 | + | i.restoreFailedSessionTitle() | |
| 694 | + | i.mu.Lock() | |
| 695 | + | i.statusErr = "import: load failed: " + err.Error() | |
| 696 | + | i.mu.Unlock() | |
| 697 | + | i.invalidate() | |
| 698 | + | return | |
| 699 | + | } | |
| 700 | + | i.restoreLoadedSessionTitle() | |
| 701 | + | state := i.restoreCurrentCompactHandoff() | |
| 702 | + | i.mu.Lock() | |
| 703 | + | i.statusOK = "imported and switched to session " + friendlyPath(newPath) | |
| 704 | + | i.statusErr = "" | |
| 705 | + | if i.agent != nil { | |
| 706 | + | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |
| 707 | + | i.cumUsage = i.agent.Cost() | |
| 708 | + | last := i.agent.LastTurnUsage() | |
| 709 | + | i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens | |
| 710 | + | if len(i.view.Messages) > initialResumeTailLimit { | |
| 711 | + | i.view.TailLimit = initialResumeTailLimit | |
| 712 | + | } else { | |
| 713 | + | i.view.TailLimit = 0 | |
| 714 | + | } | |
| 715 | + | i.view.InvalidateRenderCache() | |
| 716 | + | } | |
| 717 | + | i.mu.Unlock() | |
| 718 | + | i.invalidate() | |
| 719 | + | if state.reason != compactContinuationNone { | |
| 720 | + | i.startRestoredCompactHandoff(i.runCtx) | |
| 721 | + | } | |
| 722 | + | } | |
| 723 | + | func defaultExportDir() string { | |
| 724 | + | home, err := os.UserHomeDir() | |
| 725 | + | if err != nil || home == "" { | |
| 726 | + | return os.TempDir() | |
| 727 | + | } | |
| 728 | + | downloads := filepath.Join(home, "Downloads") | |
| 729 | + | if fi, err := os.Stat(downloads); err == nil && fi.IsDir() { | |
| 730 | + | return downloads | |
| 731 | + | } | |
| 732 | + | return home | |
| 733 | + | } | |
| 734 | + | func expandTilde(p string) string { | |
| 735 | + | if p == "" || p[0] != '~' { | |
| 736 | + | return p | |
| 737 | + | } | |
| 738 | + | home, err := os.UserHomeDir() | |
| 739 | + | if err != nil || home == "" { | |
| 740 | + | return p | |
| 741 | + | } | |
| 742 | + | if len(p) == 1 { | |
| 743 | + | return home | |
| 744 | + | } | |
| 745 | + | if p[1] == '/' || p[1] == filepath.Separator { | |
| 746 | + | return filepath.Join(home, p[2:]) | |
| 747 | + | } | |
| 748 | + | return p | |
| 749 | + | } | |
| 750 | + | func unquotePath(p string) string { | |
| 751 | + | p = strings.TrimSpace(p) | |
| 752 | + | if len(p) >= 2 { | |
| 753 | + | first := p[0] | |
| 754 | + | last := p[len(p)-1] | |
| 755 | + | if (first == '\'' && last == '\'') || (first == '"' && last == '"') { | |
| 756 | + | p = p[1 : len(p)-1] | |
| 757 | + | } | |
| 758 | + | } | |
| 759 | + | return p | |
| 760 | + | } | |
| 761 | + | func friendlyPath(p string) string { | |
| 762 | + | home, err := os.UserHomeDir() | |
| 763 | + | if err != nil || home == "" { | |
| 764 | + | return p | |
| 765 | + | } | |
| 766 | + | if strings.HasPrefix(p, home+string(filepath.Separator)) { | |
| 767 | + | return "~" + p[len(home):] | |
| 768 | + | } | |
| 769 | + | return p | |
| 770 | + | } | |
| 771 | + | func (i *Interactive) doSessionFork() { | |
| 772 | + | path := i.currentSessionPath() | |
| 773 | + | if path == "" { | |
| 774 | + | i.mu.Lock() | |
| 775 | + | i.statusErr = "fork: no session is active (running with --no-session?)" | |
| 776 | + | i.mu.Unlock() | |
| 777 | + | i.invalidate() | |
| 778 | + | return | |
| 779 | + | } | |
| 780 | + | i.mu.Lock() | |
| 781 | + | busy := i.busy || i.shellRunning || i.compacting || i.autoCompacting || i.sessionLoading | |
| 782 | + | queued := len(i.queued) != 0 | |
| 783 | + | ag := i.agent | |
| 784 | + | i.mu.Unlock() | |
| 785 | + | if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 { | |
| 786 | + | i.mu.Lock() | |
| 787 | + | i.statusErr = "fork: wait until the current turn and queued messages finish" | |
| 788 | + | i.mu.Unlock() | |
| 789 | + | i.invalidate() | |
| 790 | + | return | |
| 791 | + | } | |
| 792 | + | if i.cfg.FlushSession != nil { | |
| 793 | + | i.cfg.FlushSession() | |
| 794 | + | } | |
| 795 | + | snapshot, err := core.ReadSessionSnapshot(path) | |
| 796 | + | if err != nil { | |
| 797 | + | i.mu.Lock() | |
| 798 | + | i.statusErr = "fork: " + err.Error() | |
| 799 | + | i.mu.Unlock() | |
| 800 | + | i.invalidate() | |
| 801 | + | return | |
| 802 | + | } | |
| 803 | + | msgs := snapshot.Messages | |
| 804 | + | if len(msgs) == 0 { | |
| 805 | + | i.mu.Lock() | |
| 806 | + | i.statusErr = "fork: transcript is empty; nothing to fork from" | |
| 807 | + | i.mu.Unlock() | |
| 808 | + | i.invalidate() | |
| 809 | + | return | |
| 810 | + | } | |
| 811 | + | i.pendingFork = true | |
| 812 | + | i.jumpDialog.Open(msgs, "") | |
| 813 | + | i.invalidate() | |
| 814 | + | } | |
| 815 | + | func (i *Interactive) doSessionTree() { | |
| 816 | + | i.openSessionTree() | |
| 817 | + | } | |
| 818 | + | func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) { | |
| 819 | + | target := sessionTreeTarget{ | |
| 820 | + | SourcePath: src, | |
| 821 | + | EffectiveIndex: msgIdx, | |
| 822 | + | SelectionBoundary: msgIdx + 1, | |
| 823 | + | Role: role, | |
| 824 | + | UserDraft: prompt, | |
| 825 | + | Boundary: sessionTreeMessageBoundary, | |
| 826 | + | } | |
| 827 | + | if role == provider.RoleUser { | |
| 828 | + | target.SelectionBoundary = msgIdx | |
| 829 | + | } | |
| 830 | + | i.applySessionTreeTarget(target, turnNo) | |
| 831 | + | } | |
| 832 | + | func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo int) { | |
| 833 | + | // Selection is a state-changing operation too. Use the selection gate so | |
| 834 | + | // a queued/busy turn cannot race the hidden branch or accidentally submit | |
| 835 | + | // a provider turn while the session is being swapped. The open gate cannot | |
| 836 | + | // be reused here because the tree dialog itself owns the keyboard. | |
| 837 | + | if !i.canCommitSessionTreeSelection() { | |
| 838 | + | i.setSessionTreeError("tree: session branching is not available in this build") | |
| 839 | + | return | |
| 840 | + | } | |
| 841 | + | src := target.SourcePath | |
| 842 | + | if src == "" { | |
| 843 | + | src = i.currentSessionPath() | |
| 844 | + | } | |
| 845 | + | if src == "" { | |
| 846 | + | i.setSessionTreeError("tree: no session is active") | |
| 847 | + | return | |
| 848 | + | } | |
| 849 | + | ||
| 850 | + | // Read and validate the selected row before flushing or creating anything. | |
| 851 | + | // This gives tool-call/result pairs an atomic boundary and prevents a | |
| 852 | + | // malformed selection from changing the active session. Historical rows | |
| 853 | + | // come from a pre-compaction segment; current rows use the effective | |
| 854 | + | // snapshot that the running agent resumes from. | |
| 855 | + | var msgs []provider.Message | |
| 856 | + | var historicalSegment core.SessionHistorySegment | |
| 857 | + | if target.Historical { | |
| 858 | + | history, err := core.ReadSessionHistory(src) | |
| 859 | + | if err != nil { | |
| 860 | + | i.setSessionTreeError("tree: read selection: " + err.Error()) | |
| 861 | + | return | |
| 862 | + | } | |
| 863 | + | if target.HistorySegment < 0 || target.HistorySegment >= len(history.Segments) { | |
| 864 | + | i.setSessionTreeError("tree: selected history segment is unavailable") | |
| 865 | + | return | |
| 866 | + | } | |
| 867 | + | historicalSegment = history.Segments[target.HistorySegment] | |
| 868 | + | msgs = historicalSegment.Messages | |
| 869 | + | } else { | |
| 870 | + | sess, current, err := core.OpenSession(src) | |
| 871 | + | if err != nil { | |
| 872 | + | i.setSessionTreeError("tree: read selection: " + err.Error()) | |
| 873 | + | return | |
| 874 | + | } | |
| 875 | + | if err := sess.Close(); err != nil { | |
| 876 | + | i.setSessionTreeError("tree: close selection: " + err.Error()) | |
| 877 | + | return | |
| 878 | + | } | |
| 879 | + | msgs = current | |
| 880 | + | } | |
| 881 | + | selection, err := sessionTreeSelection(msgs, target) | |
| 882 | + | if err != nil { | |
| 883 | + | i.setSessionTreeError("tree: " + err.Error()) | |
| 884 | + | return | |
| 885 | + | } | |
| 886 | + | if i.cfg.FlushSession != nil { | |
| 887 | + | i.cfg.FlushSession() | |
| 888 | + | } | |
| 889 | + | var newPath string | |
| 890 | + | if target.Historical { | |
| 891 | + | newPath, err = core.BranchSessionHiddenFromHistory(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, historicalSegment, selection.upTo) | |
| 892 | + | } else { | |
| 893 | + | newPath, err = core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo) | |
| 894 | + | } | |
| 895 | + | if err != nil { | |
| 896 | + | i.setSessionTreeError("tree: " + err.Error()) | |
| 897 | + | return | |
| 898 | + | } | |
| 899 | + | i.markSessionTitleSwitching() | |
| 900 | + | if err := i.cfg.LoadSession(newPath); err != nil { | |
| 901 | + | i.restoreFailedSessionTitle() | |
| 902 | + | i.setSessionTreeError("tree: checkout failed: " + err.Error()) | |
| 903 | + | return | |
| 904 | + | } | |
| 905 | + | state := i.restoreCurrentCompactHandoff() | |
| 906 | + | ||
| 907 | + | // LoadSession is intentionally the old func(string) error callback: the | |
| 908 | + | // CLI and embedders already own agent/session swapping. Refresh the view | |
| 909 | + | // from the swapped agent here instead of requiring a new callback result. | |
| 910 | + | i.resetSessionTitleForFreshBranch() | |
| 911 | + | i.scrollToBottom() | |
| 912 | + | i.mu.Lock() | |
| 913 | + | i.lastCtxInput = 0 | |
| 914 | + | if i.agent != nil { | |
| 915 | + | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |
| 916 | + | i.cumUsage = i.agent.Cost() | |
| 917 | + | last := i.agent.LastTurnUsage() | |
| 918 | + | i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens | |
| 919 | + | i.view.InvalidateRenderCache() | |
| 920 | + | } | |
| 921 | + | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 922 | + | i.toolOrder = nil | |
| 923 | + | i.toolGate = map[string]int{} | |
| 924 | + | i.extNotes = nil | |
| 925 | + | if selection.restoreDraft { | |
| 926 | + | i.ed.SetValue(selection.draftText) | |
| 927 | + | i.clipboardImages = selection.images | |
| 928 | + | i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo) | |
| 929 | + | } else { | |
| 930 | + | i.ed.Clear() | |
| 931 | + | i.clipboardImages = nil | |
| 932 | + | i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo) | |
| 933 | + | } | |
| 934 | + | i.statusErr = "" | |
| 935 | + | i.mu.Unlock() | |
| 936 | + | i.scrollToBottom() | |
| 937 | + | // Selection does not submit, start, or queue the restored user draft; only | |
| 938 | + | // a later explicit Enter does that. An inherited compact handoff may resume | |
| 939 | + | // its hidden continuation below when this branch has the complete effective | |
| 940 | + | // transcript, without submitting the restored draft. | |
| 941 | + | i.invalidate() | |
| 942 | + | if state.reason != compactContinuationNone { | |
| 943 | + | i.startRestoredCompactHandoff(i.runCtx) | |
| 944 | + | } | |
| 945 | + | } | |
| 946 | + | func (i *Interactive) applyForkSelection(msgIdx int) { | |
| 947 | + | i.pendingFork = false | |
| 948 | + | src := i.currentSessionPath() | |
| 949 | + | if src == "" { | |
| 950 | + | i.mu.Lock() | |
| 951 | + | i.statusErr = "fork: no session is active" | |
| 952 | + | i.mu.Unlock() | |
| 953 | + | i.invalidate() | |
| 954 | + | return | |
| 955 | + | } | |
| 956 | + | if i.cfg.FlushSession != nil { | |
| 957 | + | i.cfg.FlushSession() | |
| 958 | + | } | |
| 959 | + | // msgIdx is 0-indexed message position; copy msgIdx+1 rows so | |
| 960 | + | // the selected user message is included. | |
| 961 | + | upTo := msgIdx + 1 | |
| 962 | + | newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo) | |
| 963 | + | if err != nil { | |
| 964 | + | i.mu.Lock() | |
| 965 | + | i.statusErr = "fork: " + err.Error() | |
| 966 | + | i.mu.Unlock() | |
| 967 | + | i.invalidate() | |
| 968 | + | return | |
| 969 | + | } | |
| 970 | + | if i.cfg.LoadSession == nil { | |
| 971 | + | i.mu.Lock() | |
| 972 | + | i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)" | |
| 973 | + | i.statusErr = "" | |
| 974 | + | i.mu.Unlock() | |
| 975 | + | i.invalidate() | |
| 976 | + | return | |
| 977 | + | } | |
| 978 | + | i.markSessionTitleSwitching() | |
| 979 | + | if err := i.cfg.LoadSession(newPath); err != nil { | |
| 980 | + | i.restoreFailedSessionTitle() | |
| 981 | + | i.mu.Lock() | |
| 982 | + | i.statusErr = "fork: switch failed: " + err.Error() | |
| 983 | + | i.mu.Unlock() | |
| 984 | + | i.invalidate() | |
| 985 | + | return | |
| 986 | + | } | |
| 987 | + | state := i.restoreCurrentCompactHandoff() | |
| 988 | + | i.resetSessionTitleForFreshBranch() | |
| 989 | + | i.mu.Lock() | |
| 990 | + | i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath) | |
| 991 | + | i.statusErr = "" | |
| 992 | + | i.mu.Unlock() | |
| 993 | + | i.invalidate() | |
| 994 | + | if state.reason != compactContinuationNone { | |
| 995 | + | i.startRestoredCompactHandoff(i.runCtx) | |
| 996 | + | } | |
| 997 | + | } | |
| 998 | + | func formatInt(n int) string { | |
| 999 | + | return fmt.Sprintf("%d", n) | |
| 1000 | + | } | |
| 1001 | + | func assistantText(m provider.Message) string { | |
| 1002 | + | var sb strings.Builder | |
| 1003 | + | for _, c := range m.Content { | |
| 1004 | + | if tb, ok := c.(provider.TextBlock); ok { | |
| 1005 | + | if sb.Len() > 0 { | |
| 1006 | + | sb.WriteByte('\n') | |
| 1007 | + | } | |
| 1008 | + | sb.WriteString(tb.Text) | |
| 1009 | + | } | |
| 1010 | + | } | |
| 1011 | + | return sb.String() | |
| 1012 | + | } | |
| 1013 | + | func (i *Interactive) resetTranscriptRenderLocked() { | |
| 1014 | + | i.view.InvalidateRenderCache() | |
| 1015 | + | i.chatCacheValid = false | |
| 1016 | + | i.prevChatLen = 0 | |
| 1017 | + | i.prevChatCols = 0 | |
| 1018 | + | i.prevChatRows = 0 | |
| 1019 | + | i.prevScrollOffset = 0 | |
| 1020 | + | i.requestRendererInvalidate() | |
| 1021 | + | } | |
| 1022 | + | func (i *Interactive) resetStreamingStateLocked() { | |
| 1023 | + | i.streaming.Reset() | |
| 1024 | + | i.streamPending = i.streamPending[:0] | |
| 1025 | + | i.streamFlushPending = false | |
| 1026 | + | i.streamOn = false | |
| 1027 | + | i.openAllToolGatesLocked() | |
| 1028 | + | } | |
| 1029 | + | func (i *Interactive) openAllToolGatesLocked() { | |
| 1030 | + | for id := range i.toolGate { | |
| 1031 | + | i.toolGate[id] = 0 | |
| 1032 | + | } | |
| 1033 | + | } | |
| 1034 | + | func (i *Interactive) gateToolLocked(id string) { | |
| 1035 | + | if _, ok := i.toolGate[id]; ok { | |
| 1036 | + | return | |
| 1037 | + | } | |
| 1038 | + | if !i.streamOn { | |
| 1039 | + | i.toolGate[id] = 0 | |
| 1040 | + | return | |
| 1041 | + | } | |
| 1042 | + | i.toolGate[id] = i.streaming.Len() + len(i.streamPending) | |
| 1043 | + | } | |
| 1044 | + | func (i *Interactive) toolGateOpenLocked(id string) bool { | |
| 1045 | + | gate, ok := i.toolGate[id] | |
| 1046 | + | if !ok || gate == 0 { | |
| 1047 | + | return true | |
| 1048 | + | } | |
| 1049 | + | return i.streaming.Len() >= gate | |
| 1050 | + | } | |
| 1051 | + | func (i *Interactive) assistantMessageSideEffects(m provider.Message) { | |
| 1052 | + | if i.cfg.OnAssistant != nil { | |
| 1053 | + | i.cfg.OnAssistant(m) | |
| 1054 | + | } | |
| 1055 | + | if i.telegramBridge != nil && i.telegramBridge.Active() { | |
| 1056 | + | var sb strings.Builder | |
| 1057 | + | for _, c := range m.Content { | |
| 1058 | + | if tb, ok := c.(provider.TextBlock); ok { | |
| 1059 | + | if sb.Len() > 0 { | |
| 1060 | + | sb.WriteString("\n") | |
| 1061 | + | } | |
| 1062 | + | sb.WriteString(tb.Text) | |
| 1063 | + | } | |
| 1064 | + | } | |
| 1065 | + | if text := sb.String(); strings.TrimSpace(text) != "" { | |
| 1066 | + | go i.telegramBridge.OnAssistantText(text) | |
| 1067 | + | } | |
| 1068 | + | } | |
| 1069 | + | } | |
| 1070 | + | func (i *Interactive) runStreamPacer(ctx context.Context) { | |
| 1071 | + | t := time.NewTicker(paintPaceInterval) | |
| 1072 | + | defer t.Stop() | |
| 1073 | + | for { | |
| 1074 | + | select { | |
| 1075 | + | case <-ctx.Done(): | |
| 1076 | + | return | |
| 1077 | + | case <-t.C: | |
| 1078 | + | i.mu.Lock() | |
| 1079 | + | if len(i.streamPending) == 0 { | |
| 1080 | + | // EvAssistantMessage already fired but the pacer | |
| 1081 | + | // was still draining a tick ago. Everything is now | |
| 1082 | + | // painted; clear the streaming flags so the next | |
| 1083 | + | // redraw shows the finalised transcript message | |
| 1084 | + | // and hides the streaming overlay. | |
| 1085 | + | if i.streamFlushPending { | |
| 1086 | + | i.streamFlushPending = false | |
| 1087 | + | i.streaming.Reset() | |
| 1088 | + | i.streamOn = false | |
| 1089 | + | i.openAllToolGatesLocked() | |
| 1090 | + | i.mu.Unlock() | |
| 1091 | + | i.invalidate() | |
| 1092 | + | continue | |
| 1093 | + | } | |
| 1094 | + | i.mu.Unlock() | |
| 1095 | + | continue | |
| 1096 | + | } | |
| 1097 | + | n := paintPaceRate | |
| 1098 | + | if n > len(i.streamPending) { | |
| 1099 | + | n = len(i.streamPending) | |
| 1100 | + | } | |
| 1101 | + | i.streaming.WriteString(string(i.streamPending[:n])) | |
| 1102 | + | i.streamPending = i.streamPending[n:] | |
| 1103 | + | i.mu.Unlock() | |
| 1104 | + | i.invalidate() | |
| 1105 | + | } | |
| 1106 | + | } | |
| 1107 | + | } | |
A packages/agent/modes/interactive_settings.go +1237 -0
| @@ -0,0 +1,1237 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "runtime" | |
| 7 | + | "strconv" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zut/packages/provider" | |
| 11 | + | "github.com/bnema/zut/packages/tui" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func effectiveImageProtocol(override *bool) tui.ImageProtocol { | |
| 15 | + | detected := tui.DetectImageProtocol() | |
| 16 | + | if override == nil { | |
| 17 | + | return detected | |
| 18 | + | } | |
| 19 | + | if !*override { | |
| 20 | + | return tui.ImageProtocolNone | |
| 21 | + | } | |
| 22 | + | return detected | |
| 23 | + | } | |
| 24 | + | func imageProtocolName(p tui.ImageProtocol) string { | |
| 25 | + | switch p { | |
| 26 | + | case tui.ImageProtocolKitty: | |
| 27 | + | return "kitty/ghostty" | |
| 28 | + | case tui.ImageProtocolITerm2: | |
| 29 | + | return "iTerm2" | |
| 30 | + | default: | |
| 31 | + | return "none" | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | func onOff(v bool) string { | |
| 35 | + | if v { | |
| 36 | + | return "enabled" | |
| 37 | + | } | |
| 38 | + | return "disabled" | |
| 39 | + | } | |
| 40 | + | func (i *Interactive) applyInputCursorColor() { | |
| 41 | + | if i == nil || i.cfg.Terminal == nil { | |
| 42 | + | return | |
| 43 | + | } | |
| 44 | + | color := tui.Color256(15) | |
| 45 | + | if i.cursorDimmed { | |
| 46 | + | color = i.cfg.Theme.DimColor(color, modalBackdropDimPercent) | |
| 47 | + | } | |
| 48 | + | _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor(color) + tui.CursorShapeBlock())) | |
| 49 | + | } | |
| 50 | + | func (i *Interactive) setInputCursorDimmed(dimmed bool) { | |
| 51 | + | if i == nil || i.cursorDimmed == dimmed { | |
| 52 | + | return | |
| 53 | + | } | |
| 54 | + | i.cursorDimmed = dimmed | |
| 55 | + | i.applyInputCursorColor() | |
| 56 | + | } | |
| 57 | + | func reasoningSettingOptions(levels []string) []settingsOption { | |
| 58 | + | descriptions := map[string]string{ | |
| 59 | + | "": "no reasoning", | |
| 60 | + | "minimum": "very brief (~1k tokens)", | |
| 61 | + | "low": "light (~2k tokens)", | |
| 62 | + | "medium": "moderate (~8k tokens)", | |
| 63 | + | "high": "deep (~16k tokens)", | |
| 64 | + | "xhigh": "extra-high effort", | |
| 65 | + | "max": "unconstrained effort", | |
| 66 | + | } | |
| 67 | + | options := make([]settingsOption, 0, len(levels)) | |
| 68 | + | for _, level := range levels { | |
| 69 | + | label := level | |
| 70 | + | if label == "" { | |
| 71 | + | label = "off" | |
| 72 | + | } | |
| 73 | + | options = append(options, settingsOption{value: level, label: label, desc: descriptions[level]}) | |
| 74 | + | } | |
| 75 | + | return options | |
| 76 | + | } | |
| 77 | + | func (i *Interactive) reasoningSettingItem() settingsItem { | |
| 78 | + | levels := []string{"", "minimum", "low", "medium", "high", "xhigh", "max"} | |
| 79 | + | reasoning := provider.NormalizeReasoning(i.cfg.Reasoning) | |
| 80 | + | desc := "reasoning depth for reasoning-capable models" | |
| 81 | + | hint := "" | |
| 82 | + | if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil { | |
| 83 | + | levels = provider.AvailableReasoningLevels(m) | |
| 84 | + | reasoning = provider.ClampReasoningForModel(m, reasoning) | |
| 85 | + | if !m.Reasoning { | |
| 86 | + | hint = "current model does not support reasoning" | |
| 87 | + | desc += "; current model does not support reasoning" | |
| 88 | + | } else if len(levels) == 1 { | |
| 89 | + | hint = "current model has no adjustable reasoning levels" | |
| 90 | + | desc += "; current model has no adjustable reasoning levels" | |
| 91 | + | } else { | |
| 92 | + | desc += "; only levels supported by the current model are shown" | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | options := reasoningSettingOptions(levels) | |
| 96 | + | choice := 0 | |
| 97 | + | for idx, opt := range options { | |
| 98 | + | if opt.value == reasoning { | |
| 99 | + | choice = idx | |
| 100 | + | break | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | return settingsItem{ | |
| 105 | + | key: "reasoning", | |
| 106 | + | label: "reasoning level", | |
| 107 | + | desc: desc, | |
| 108 | + | options: options, | |
| 109 | + | choice: choice, | |
| 110 | + | hint: hint, | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | func (i *Interactive) openReasoningDialog() { | |
| 114 | + | i.settingsDialog.OpenDirectOption(i.reasoningSettingItem()) | |
| 115 | + | } | |
| 116 | + | func (i *Interactive) openSettingsDialog() { | |
| 117 | + | detected := tui.DetectImageProtocol() | |
| 118 | + | imgEnabled := detected != tui.ImageProtocolNone | |
| 119 | + | if i.cfg.InlineImagesEnabled != nil { | |
| 120 | + | imgEnabled = *i.cfg.InlineImagesEnabled | |
| 121 | + | } | |
| 122 | + | imgDisabled := detected == tui.ImageProtocolNone | |
| 123 | + | imgHint := "" | |
| 124 | + | if imgDisabled { | |
| 125 | + | imgEnabled = false | |
| 126 | + | imgHint = "this terminal does not support inline images" | |
| 127 | + | } else { | |
| 128 | + | imgHint = "terminal supports " + imageProtocolName(detected) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) | |
| 132 | + | terminalTitles := terminalTitleEnabled(i.cfg.TerminalTitleEnabled) | |
| 133 | + | autoSubagents := false | |
| 134 | + | if i.cfg.AutoSubagentsEnabled != nil { | |
| 135 | + | autoSubagents = *i.cfg.AutoSubagentsEnabled | |
| 136 | + | } | |
| 137 | + | autoSubagentsDisabled := !i.autoSubagentsAvailable() | |
| 138 | + | autoSubagentsHint := i.autoSubagentsUnavailableHint() | |
| 139 | + | if autoSubagentsDisabled { | |
| 140 | + | autoSubagents = false | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | ponytailEnabled := i.ponytailEnabled() | |
| 144 | + | ponytailDisabled := !i.ponytailSettingsAvailable() | |
| 145 | + | ponytailHint := "" | |
| 146 | + | if ponytailDisabled { | |
| 147 | + | ponytailHint = "requires persistent settings and live prompt refresh support" | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | webSearchEnabled := i.webSearchEffectiveEnabled() | |
| 151 | + | webSearchDisabled := !i.webSearchSettingsAvailable() | |
| 152 | + | webSearchHint := i.webSearchUnavailableHint() | |
| 153 | + | ||
| 154 | + | fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode | |
| 155 | + | fastModeHint := "OpenAI service tier" | |
| 156 | + | if !provider.SupportsFastMode(i.cfg.Provider) { | |
| 157 | + | fastModeHint = "only supported for OpenAI providers" | |
| 158 | + | } | |
| 159 | + | lspEnabled := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled | |
| 160 | + | subagentLSPEnabled := i.cfg.SubagentLSPEnabled == nil || *i.cfg.SubagentLSPEnabled | |
| 161 | + | ||
| 162 | + | jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault | |
| 163 | + | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | |
| 164 | + | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | |
| 165 | + | compactMode := i.compactModeEnabled() | |
| 166 | + | showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup | |
| 167 | + | inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) | |
| 168 | + | statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition) | |
| 169 | + | workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition) | |
| 170 | + | quickItems := i.quickModelSettingItems() | |
| 171 | + | ||
| 172 | + | autoCompactOptions := []settingsOption{ | |
| 173 | + | {value: "0", label: "off", desc: "disable context-percentage triggers; payload-too-large recovery stays enabled"}, | |
| 174 | + | {value: "70", label: "70%", desc: "compact earlier to keep more context headroom"}, | |
| 175 | + | {value: "80", label: "80%", desc: "compact with moderate context headroom"}, | |
| 176 | + | {value: "85", label: "85%", desc: "default balance between history and headroom"}, | |
| 177 | + | {value: "90", label: "90%", desc: "retain more history before compacting"}, | |
| 178 | + | {value: "95", label: "95%", desc: "retain most history before compacting"}, | |
| 179 | + | } | |
| 180 | + | autoCompactThreshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold) | |
| 181 | + | autoCompactChoice := 0 | |
| 182 | + | for idx, opt := range autoCompactOptions { | |
| 183 | + | if opt.value == strconv.Itoa(autoCompactThreshold) { | |
| 184 | + | autoCompactChoice = idx | |
| 185 | + | break | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | reasoningItem := i.reasoningSettingItem() | |
| 190 | + | ||
| 191 | + | themeName := i.cfg.ThemeName | |
| 192 | + | if themeName == "" { | |
| 193 | + | themeName = "auto" | |
| 194 | + | } | |
| 195 | + | if themeName != "auto" && !tui.ThemeExists(i.cfg.ZutHome, themeName) { | |
| 196 | + | themeName = "auto" | |
| 197 | + | i.cfg.ThemeName = "" | |
| 198 | + | if i.cfg.SettingsStore != nil { | |
| 199 | + | _ = i.cfg.SettingsStore.SetTheme("auto") | |
| 200 | + | } | |
| 201 | + | i.applyThemeNow("auto") | |
| 202 | + | } | |
| 203 | + | themeOptions := []settingsOption{} | |
| 204 | + | themeChoice := 0 | |
| 205 | + | availableThemes := tui.AvailableThemes(i.cfg.ZutHome) | |
| 206 | + | if i.cfg.ExtensionThemes != nil { | |
| 207 | + | availableThemes = append(availableThemes, i.cfg.ExtensionThemes()...) | |
| 208 | + | } | |
| 209 | + | for idx, opt := range availableThemes { | |
| 210 | + | themeOptions = append(themeOptions, settingsOption{value: opt.Value, label: opt.Label, desc: opt.Description}) | |
| 211 | + | if opt.Value == themeName { | |
| 212 | + | themeChoice = idx | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | inputStyleOptions := []settingsOption{ | |
| 217 | + | {value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"}, | |
| 218 | + | {value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"}, | |
| 219 | + | {value: tui.InputStyleBlock, label: "block", desc: "render the input as a user-bubble-style block"}, | |
| 220 | + | } | |
| 221 | + | inputStyleChoice := 0 | |
| 222 | + | for idx, opt := range inputStyleOptions { | |
| 223 | + | if opt.value == inputStyle { | |
| 224 | + | inputStyleChoice = idx | |
| 225 | + | break | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | statusPositionOptions := []settingsOption{ | |
| 229 | + | {value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"}, | |
| 230 | + | {value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"}, | |
| 231 | + | } | |
| 232 | + | statusPositionChoice := 0 | |
| 233 | + | for idx, opt := range statusPositionOptions { | |
| 234 | + | if opt.value == statusPosition { | |
| 235 | + | statusPositionChoice = idx | |
| 236 | + | break | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | workingPositionOptions := []settingsOption{ | |
| 241 | + | {value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"}, | |
| 242 | + | {value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"}, | |
| 243 | + | } | |
| 244 | + | workingPositionChoice := 0 | |
| 245 | + | for idx, opt := range workingPositionOptions { | |
| 246 | + | if opt.value == workingPosition { | |
| 247 | + | workingPositionChoice = idx | |
| 248 | + | break | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | items := []settingsItem{ | |
| 253 | + | { | |
| 254 | + | key: "inline_images_enabled", | |
| 255 | + | label: "render images when supported", | |
| 256 | + | desc: "draw screenshots inline instead of showing a text placeholder", | |
| 257 | + | value: imgEnabled, | |
| 258 | + | disabled: imgDisabled, | |
| 259 | + | hint: imgHint, | |
| 260 | + | }, | |
| 261 | + | { | |
| 262 | + | key: "terminal_alerts_enabled", | |
| 263 | + | label: "terminal alerts", | |
| 264 | + | desc: "ring the terminal when the main session needs attention or an extension raises an alert", | |
| 265 | + | value: terminalAlerts, | |
| 266 | + | }, | |
| 267 | + | { | |
| 268 | + | key: "terminal_title_enabled", | |
| 269 | + | label: "AI terminal titles", | |
| 270 | + | desc: "ask the active model for a concise title after the first prompt; uses one extra hidden request", | |
| 271 | + | value: terminalTitles, | |
| 272 | + | }, | |
| 273 | + | { | |
| 274 | + | key: "auto_subagents_enabled", | |
| 275 | + | label: "Subagent Orchestrator", | |
| 276 | + | desc: "automatically delegate coding work to sub-agents; tools stay available when disabled", | |
| 277 | + | value: autoSubagents, | |
| 278 | + | disabled: autoSubagentsDisabled, | |
| 279 | + | hint: autoSubagentsHint, | |
| 280 | + | }, | |
| 281 | + | { | |
| 282 | + | key: "ponytail_enabled", | |
| 283 | + | label: "ponytail coding mode", | |
| 284 | + | desc: "apply compact coding guidance that favors small, validated, maintainable changes", | |
| 285 | + | value: ponytailEnabled, | |
| 286 | + | disabled: ponytailDisabled, | |
| 287 | + | hint: ponytailHint, | |
| 288 | + | }, | |
| 289 | + | { | |
| 290 | + | key: "web_search_enabled", | |
| 291 | + | label: "web search", | |
| 292 | + | desc: "search DuckDuckGo for bounded public-web sources; sends queries to the public backend", | |
| 293 | + | value: webSearchEnabled, | |
| 294 | + | disabled: webSearchDisabled, | |
| 295 | + | hint: webSearchHint, | |
| 296 | + | }, | |
| 297 | + | { | |
| 298 | + | key: "fast_mode", | |
| 299 | + | label: "fast mode", | |
| 300 | + | desc: "request the provider's fast tier where supported; unsupported providers return an error", | |
| 301 | + | value: fastMode, | |
| 302 | + | hint: fastModeHint, | |
| 303 | + | }, | |
| 304 | + | { | |
| 305 | + | key: "lsp_enabled", | |
| 306 | + | label: "lsp in main session", | |
| 307 | + | desc: "enable the lsp tool and code diagnostics for the main agent", | |
| 308 | + | value: lspEnabled, | |
| 309 | + | }, | |
| 310 | + | { | |
| 311 | + | key: "subagent_lsp_enabled", | |
| 312 | + | label: "lsp in sub-agents", | |
| 313 | + | desc: "allow newly spawned background sub-agents to use the lsp tool", | |
| 314 | + | value: subagentLSPEnabled, | |
| 315 | + | }, | |
| 316 | + | { | |
| 317 | + | key: "auto_compact_threshold", | |
| 318 | + | label: "auto-compact threshold", | |
| 319 | + | desc: "choose how full the model context can get before zut condenses conversation history", | |
| 320 | + | options: autoCompactOptions, | |
| 321 | + | choice: autoCompactChoice, | |
| 322 | + | }, | |
| 323 | + | { | |
| 324 | + | key: "jail_by_default", | |
| 325 | + | label: "jail new sessions by default", | |
| 326 | + | desc: "confine tools to the session working directory unless /unjail is used", | |
| 327 | + | value: jailByDefault, | |
| 328 | + | }, | |
| 329 | + | { | |
| 330 | + | key: "recursive_file_suggest", | |
| 331 | + | label: "recursive @-file search", | |
| 332 | + | desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time", | |
| 333 | + | value: recursiveFiles, | |
| 334 | + | }, | |
| 335 | + | { | |
| 336 | + | key: "respect_gitignore", | |
| 337 | + | label: "hide gitignored files in @-picker", | |
| 338 | + | desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @", | |
| 339 | + | value: respectGitignore, | |
| 340 | + | }, | |
| 341 | + | { | |
| 342 | + | key: "compact_mode", | |
| 343 | + | label: "compact transcript rendering", | |
| 344 | + | desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles", | |
| 345 | + | value: compactMode, | |
| 346 | + | }, | |
| 347 | + | { | |
| 348 | + | key: "show_instructions_at_startup", | |
| 349 | + | label: "show loaded resources at startup", | |
| 350 | + | desc: "list loaded context files, extensions, and user-installed skills above the transcript", | |
| 351 | + | value: showInstructions, | |
| 352 | + | }, | |
| 353 | + | { | |
| 354 | + | key: "tui_settings", | |
| 355 | + | label: "tui settings", | |
| 356 | + | desc: "choose input chrome in its order around the prompt", | |
| 357 | + | children: []settingsItem{ | |
| 358 | + | { | |
| 359 | + | key: "tui_status_position", | |
| 360 | + | label: "status position", | |
| 361 | + | desc: "place model, usage, and working directory above or below the input", | |
| 362 | + | options: statusPositionOptions, | |
| 363 | + | choice: statusPositionChoice, | |
| 364 | + | }, | |
| 365 | + | { | |
| 366 | + | key: "tui_working_position", | |
| 367 | + | label: "working spinner position", | |
| 368 | + | desc: "place the working spinner above or below the input", | |
| 369 | + | options: workingPositionOptions, | |
| 370 | + | choice: workingPositionChoice, | |
| 371 | + | }, | |
| 372 | + | { | |
| 373 | + | key: "tui_input_style", | |
| 374 | + | label: "input style", | |
| 375 | + | desc: "choose between the plain prompt, lines, and a block input area", | |
| 376 | + | options: inputStyleOptions, | |
| 377 | + | choice: inputStyleChoice, | |
| 378 | + | }, | |
| 379 | + | }, | |
| 380 | + | }, | |
| 381 | + | reasoningItem, | |
| 382 | + | { | |
| 383 | + | key: "theme", | |
| 384 | + | label: "color theme", | |
| 385 | + | desc: "choose auto, inherited terminal colors, a built-in dark/light theme, or a loaded theme file", | |
| 386 | + | options: themeOptions, | |
| 387 | + | choice: themeChoice, | |
| 388 | + | }, | |
| 389 | + | } | |
| 390 | + | if len(quickItems) > 0 { | |
| 391 | + | items = append(items, settingsItem{ | |
| 392 | + | key: "quick_models", | |
| 393 | + | label: "model shortcuts", | |
| 394 | + | desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches", | |
| 395 | + | children: quickItems, | |
| 396 | + | }) | |
| 397 | + | } | |
| 398 | + | i.settingsDialog.Open(items) | |
| 399 | + | } | |
| 400 | + | func (i *Interactive) applySettingChange(act settingsAction) { | |
| 401 | + | switch { | |
| 402 | + | case strings.HasPrefix(act.Key, "quick_model_"): | |
| 403 | + | i.applyQuickModelSetting(act.Key, act.StringValue) | |
| 404 | + | case act.Key == "reasoning": | |
| 405 | + | i.applyReasoningSetting(act.StringValue) | |
| 406 | + | case act.Key == "theme": | |
| 407 | + | i.applyThemeSetting(act.StringValue) | |
| 408 | + | case act.Key == "auto_compact_threshold": | |
| 409 | + | i.applyAutoCompactThresholdSetting(act.StringValue) | |
| 410 | + | case act.Key == "tui_input_style": | |
| 411 | + | i.applyTUIInputStyleSetting(act.StringValue) | |
| 412 | + | case act.Key == "tui_status_position": | |
| 413 | + | i.applyTUIStatusPositionSetting(act.StringValue) | |
| 414 | + | case act.Key == "tui_working_position": | |
| 415 | + | i.applyTUIWorkingPositionSetting(act.StringValue) | |
| 416 | + | default: | |
| 417 | + | i.applySettingToggle(act.Key, act.Value) | |
| 418 | + | } | |
| 419 | + | } | |
| 420 | + | func (i *Interactive) applyAutoCompactThresholdSetting(value string) { | |
| 421 | + | threshold, err := strconv.Atoi(value) | |
| 422 | + | if err != nil { | |
| 423 | + | return | |
| 424 | + | } | |
| 425 | + | threshold = NormalizeAutoCompactThreshold(&threshold) | |
| 426 | + | if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok { | |
| 427 | + | if err := store.SetAutoCompactThreshold(threshold); err != nil { | |
| 428 | + | i.mu.Lock() | |
| 429 | + | i.statusErr = "settings: " + err.Error() | |
| 430 | + | i.mu.Unlock() | |
| 431 | + | return | |
| 432 | + | } | |
| 433 | + | } | |
| 434 | + | i.mu.Lock() | |
| 435 | + | i.cfg.AutoCompactThreshold = &threshold | |
| 436 | + | if threshold == 0 { | |
| 437 | + | i.statusOK = "auto-compact threshold off" | |
| 438 | + | } else { | |
| 439 | + | i.statusOK = fmt.Sprintf("auto-compact threshold %d%%", threshold) | |
| 440 | + | } | |
| 441 | + | i.statusErr = "" | |
| 442 | + | i.mu.Unlock() | |
| 443 | + | i.invalidate() | |
| 444 | + | } | |
| 445 | + | func (i *Interactive) quickModelSettingItems() []settingsItem { | |
| 446 | + | if len(i.cfg.QuickModelShortcuts) < 9 { | |
| 447 | + | next := make([]QuickModelShortcut, 9) | |
| 448 | + | copy(next, i.cfg.QuickModelShortcuts) | |
| 449 | + | i.cfg.QuickModelShortcuts = next | |
| 450 | + | } | |
| 451 | + | items := make([]settingsItem, 0, 9) | |
| 452 | + | for slot := 1; slot <= 9; slot++ { | |
| 453 | + | items = append(items, i.quickModelSettingItem(slot)) | |
| 454 | + | } | |
| 455 | + | return items | |
| 456 | + | } | |
| 457 | + | func (i *Interactive) quickModelSettingItem(slot int) settingsItem { | |
| 458 | + | current := QuickModelShortcut{} | |
| 459 | + | if slot >= 1 && len(i.cfg.QuickModelShortcuts) >= slot { | |
| 460 | + | current = i.cfg.QuickModelShortcuts[slot-1] | |
| 461 | + | } | |
| 462 | + | hint := "not assigned" | |
| 463 | + | if current.Provider != "" && current.Model != "" { | |
| 464 | + | hint = current.Provider + " / " + current.Model | |
| 465 | + | } | |
| 466 | + | return settingsItem{ | |
| 467 | + | key: "quick_model_" + strconv.Itoa(slot), | |
| 468 | + | label: "model " + strconv.Itoa(slot), | |
| 469 | + | desc: quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.", | |
| 470 | + | picker: true, | |
| 471 | + | hint: hint, | |
| 472 | + | } | |
| 473 | + | } | |
| 474 | + | func (i *Interactive) compactModeEnabled() bool { | |
| 475 | + | return i.cfg.CompactMode != nil && *i.cfg.CompactMode | |
| 476 | + | } | |
| 477 | + | func quickModelShortcutSlot(k tui.Key) int { | |
| 478 | + | if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' { | |
| 479 | + | return 0 | |
| 480 | + | } | |
| 481 | + | if runtime.GOOS == "darwin" { | |
| 482 | + | if !k.Super && !k.Ctrl { | |
| 483 | + | return 0 | |
| 484 | + | } | |
| 485 | + | } else if !k.Ctrl { | |
| 486 | + | return 0 | |
| 487 | + | } | |
| 488 | + | return int(k.Rune - '0') | |
| 489 | + | } | |
| 490 | + | func quickModelShortcutPrefix() string { | |
| 491 | + | return "Ctrl" | |
| 492 | + | } | |
| 493 | + | func quickModelShortcutLabel(slot int) string { | |
| 494 | + | return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot) | |
| 495 | + | } | |
| 496 | + | func (i *Interactive) openQuickModelPicker(slot int) { | |
| 497 | + | if slot < 1 || slot > 9 { | |
| 498 | + | return | |
| 499 | + | } | |
| 500 | + | i.quickModelAssign = slot | |
| 501 | + | current := i.cfg.Model | |
| 502 | + | if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" { | |
| 503 | + | current = i.cfg.QuickModelShortcuts[slot-1].Model | |
| 504 | + | } | |
| 505 | + | var loggedIn []string | |
| 506 | + | if i.cfg.LoggedInProviders != nil { | |
| 507 | + | loggedIn = i.cfg.LoggedInProviders() | |
| 508 | + | } | |
| 509 | + | i.modelDialog.Open(current, loggedIn, i.cfg.Reasoning) | |
| 510 | + | } | |
| 511 | + | func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) { | |
| 512 | + | i.setQuickModelShortcut(slot, providerName, model) | |
| 513 | + | } | |
| 514 | + | func (i *Interactive) applyQuickModelShortcut(slot int) { | |
| 515 | + | if slot < 1 || slot > 9 { | |
| 516 | + | return | |
| 517 | + | } | |
| 518 | + | if i.busy { | |
| 519 | + | i.mu.Lock() | |
| 520 | + | i.statusErr = "cannot switch model while a turn is running" | |
| 521 | + | i.statusOK = "" | |
| 522 | + | i.mu.Unlock() | |
| 523 | + | i.invalidate() | |
| 524 | + | return | |
| 525 | + | } | |
| 526 | + | if len(i.cfg.QuickModelShortcuts) < slot { | |
| 527 | + | i.mu.Lock() | |
| 528 | + | i.statusErr = quickModelShortcutLabel(slot) + " is not assigned" | |
| 529 | + | i.statusOK = "" | |
| 530 | + | i.mu.Unlock() | |
| 531 | + | i.invalidate() | |
| 532 | + | return | |
| 533 | + | } | |
| 534 | + | shortcut := i.cfg.QuickModelShortcuts[slot-1] | |
| 535 | + | if shortcut.Provider == "" || shortcut.Model == "" { | |
| 536 | + | i.mu.Lock() | |
| 537 | + | i.statusErr = quickModelShortcutLabel(slot) + " is not assigned" | |
| 538 | + | i.statusOK = "" | |
| 539 | + | i.mu.Unlock() | |
| 540 | + | i.invalidate() | |
| 541 | + | return | |
| 542 | + | } | |
| 543 | + | i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false) | |
| 544 | + | i.invalidate() | |
| 545 | + | } | |
| 546 | + | func (i *Interactive) applyQuickModelSetting(key, value string) { | |
| 547 | + | slotText := strings.TrimPrefix(key, "quick_model_") | |
| 548 | + | slot, err := strconv.Atoi(slotText) | |
| 549 | + | if err != nil || slot < 1 || slot > 9 { | |
| 550 | + | return | |
| 551 | + | } | |
| 552 | + | providerName, model := "", "" | |
| 553 | + | if value != "" { | |
| 554 | + | parts := strings.SplitN(value, "\t", 2) | |
| 555 | + | if len(parts) == 2 { | |
| 556 | + | providerName, model = parts[0], parts[1] | |
| 557 | + | } | |
| 558 | + | } | |
| 559 | + | i.setQuickModelShortcut(slot, providerName, model) | |
| 560 | + | } | |
| 561 | + | func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) { | |
| 562 | + | if len(i.cfg.QuickModelShortcuts) < slot { | |
| 563 | + | next := make([]QuickModelShortcut, slot) | |
| 564 | + | copy(next, i.cfg.QuickModelShortcuts) | |
| 565 | + | i.cfg.QuickModelShortcuts = next | |
| 566 | + | } | |
| 567 | + | i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model} | |
| 568 | + | if i.cfg.SettingsStore != nil { | |
| 569 | + | if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil { | |
| 570 | + | i.mu.Lock() | |
| 571 | + | i.statusErr = "settings: " + err.Error() | |
| 572 | + | i.mu.Unlock() | |
| 573 | + | return | |
| 574 | + | } | |
| 575 | + | } | |
| 576 | + | i.mu.Lock() | |
| 577 | + | if model == "" { | |
| 578 | + | i.statusOK = quickModelShortcutLabel(slot) + " cleared" | |
| 579 | + | } else { | |
| 580 | + | i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model | |
| 581 | + | } | |
| 582 | + | i.statusErr = "" | |
| 583 | + | i.mu.Unlock() | |
| 584 | + | i.refreshQuickModelSettingsItem(slot) | |
| 585 | + | i.invalidate() | |
| 586 | + | } | |
| 587 | + | func (i *Interactive) refreshQuickModelSettingsItem(slot int) { | |
| 588 | + | if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 { | |
| 589 | + | return | |
| 590 | + | } | |
| 591 | + | key := "quick_model_" + strconv.Itoa(slot) | |
| 592 | + | for idx, it := range i.settingsDialog.items { | |
| 593 | + | if it.key == key { | |
| 594 | + | i.settingsDialog.items[idx] = i.quickModelSettingItem(slot) | |
| 595 | + | return | |
| 596 | + | } | |
| 597 | + | } | |
| 598 | + | } | |
| 599 | + | func (i *Interactive) resetSettingsToggle(key string, value bool) { | |
| 600 | + | if i.settingsDialog == nil { | |
| 601 | + | return | |
| 602 | + | } | |
| 603 | + | for idx := range i.settingsDialog.items { | |
| 604 | + | if i.settingsDialog.items[idx].key == key { | |
| 605 | + | i.settingsDialog.items[idx].value = value | |
| 606 | + | return | |
| 607 | + | } | |
| 608 | + | } | |
| 609 | + | } | |
| 610 | + | func (i *Interactive) ponytailEnabled() bool { | |
| 611 | + | return i.cfg.PonytailEnabled == nil || *i.cfg.PonytailEnabled | |
| 612 | + | } | |
| 613 | + | func (i *Interactive) ponytailSettingsAvailable() bool { | |
| 614 | + | _, persistent := i.cfg.SettingsStore.(ponytailSettingsStore) | |
| 615 | + | return persistent && i.cfg.RefreshPrompt != nil | |
| 616 | + | } | |
| 617 | + | func (i *Interactive) webSearchEnabled() bool { | |
| 618 | + | return i.cfg.WebSearchEnabled == nil || *i.cfg.WebSearchEnabled | |
| 619 | + | } | |
| 620 | + | func (i *Interactive) webSearchToolAllowed() bool { | |
| 621 | + | return i.cfg.WebSearchToolAllowed == nil || *i.cfg.WebSearchToolAllowed | |
| 622 | + | } | |
| 623 | + | func (i *Interactive) webSearchEffectiveEnabled() bool { | |
| 624 | + | return i.webSearchToolAllowed() && (i.cfg.WebSearchInvocationOverride || i.webSearchEnabled()) | |
| 625 | + | } | |
| 626 | + | func (i *Interactive) webSearchSettingsAvailable() bool { | |
| 627 | + | _, persistent := i.cfg.SettingsStore.(webSearchSettingsStore) | |
| 628 | + | return i.webSearchToolAllowed() && !i.cfg.WebSearchInvocationOverride && persistent && i.cfg.RefreshTools != nil | |
| 629 | + | } | |
| 630 | + | func (i *Interactive) webSearchUnavailableHint() string { | |
| 631 | + | if !i.webSearchToolAllowed() { | |
| 632 | + | return "unavailable in this session due to the launch-time tool capability ceiling" | |
| 633 | + | } | |
| 634 | + | if i.cfg.WebSearchInvocationOverride { | |
| 635 | + | return "enabled for this session by explicit --tools web_search; the persisted setting applies to invocations without --tools" | |
| 636 | + | } | |
| 637 | + | if !i.webSearchSettingsAvailable() { | |
| 638 | + | return "requires persistent settings and live tool refresh support" | |
| 639 | + | } | |
| 640 | + | return "" | |
| 641 | + | } | |
| 642 | + | func (i *Interactive) setWebSearchAvailable(available bool) { | |
| 643 | + | // Advance before publishing the new gate/child ceiling. A resolver that | |
| 644 | + | // captured the prior generation can no longer commit after this transition. | |
| 645 | + | // Callers may hold agentMu and i.mu; the callback must complete without | |
| 646 | + | // calling back into Interactive or requiring either lock. | |
| 647 | + | i.webSearchPolicyGeneration.Add(1) | |
| 648 | + | if i.cfg.SetWebSearchAvailable != nil { | |
| 649 | + | i.cfg.SetWebSearchAvailable(available) | |
| 650 | + | } | |
| 651 | + | } | |
| 652 | + | func (i *Interactive) applyWebSearchSettingToggle(key string, value bool) { | |
| 653 | + | previous := i.webSearchEnabled() | |
| 654 | + | store, persistent := i.cfg.SettingsStore.(webSearchSettingsStore) | |
| 655 | + | if !i.webSearchSettingsAvailable() || !persistent { | |
| 656 | + | i.resetSettingsToggle(key, i.webSearchEffectiveEnabled()) | |
| 657 | + | i.mu.Lock() | |
| 658 | + | i.statusOK = "" | |
| 659 | + | i.statusErr = "web search unavailable: " + i.webSearchUnavailableHint() | |
| 660 | + | i.mu.Unlock() | |
| 661 | + | return | |
| 662 | + | } | |
| 663 | + | if err := store.SetWebSearchEnabled(value); err != nil { | |
| 664 | + | i.resetSettingsToggle(key, previous) | |
| 665 | + | i.mu.Lock() | |
| 666 | + | i.statusOK = "" | |
| 667 | + | i.statusErr = "settings: " + err.Error() | |
| 668 | + | i.mu.Unlock() | |
| 669 | + | return | |
| 670 | + | } | |
| 671 | + | val := value | |
| 672 | + | i.cfg.WebSearchEnabled = &val | |
| 673 | + | // Revoke and advance the policy generation for both directions. Enabling | |
| 674 | + | // remains fail-closed until the freshly resolved registry commits, while a | |
| 675 | + | // stale resolve from before this persisted transition is rejected. | |
| 676 | + | i.stripWebSearchTool() | |
| 677 | + | if err := i.cfg.RefreshTools(); err != nil { | |
| 678 | + | // Refresh failure leaves the old registry's state unknown. Revoke both | |
| 679 | + | // live execution and generic-child inheritance before rollback. | |
| 680 | + | i.stripWebSearchTool() | |
| 681 | + | errMsg := "settings: web search tool refresh: " + err.Error() | |
| 682 | + | if rollbackErr := store.SetWebSearchEnabled(previous); rollbackErr != nil { | |
| 683 | + | failClosed := false | |
| 684 | + | i.cfg.WebSearchEnabled = &failClosed | |
| 685 | + | i.resetSettingsToggle(key, false) | |
| 686 | + | errMsg += "; rollback persistence: " + rollbackErr.Error() | |
| 687 | + | } else { | |
| 688 | + | previousVal := previous | |
| 689 | + | i.cfg.WebSearchEnabled = &previousVal | |
| 690 | + | i.resetSettingsToggle(key, previous) | |
| 691 | + | if refreshErr := i.cfg.RefreshTools(); refreshErr != nil { | |
| 692 | + | errMsg += "; rollback refresh: " + refreshErr.Error() | |
| 693 | + | i.stripWebSearchTool() | |
| 694 | + | } | |
| 695 | + | } | |
| 696 | + | i.mu.Lock() | |
| 697 | + | i.statusOK = "" | |
| 698 | + | i.statusErr = errMsg | |
| 699 | + | i.mu.Unlock() | |
| 700 | + | return | |
| 701 | + | } | |
| 702 | + | i.mu.Lock() | |
| 703 | + | i.statusOK = "web search " + onOff(value) | |
| 704 | + | i.statusErr = "" | |
| 705 | + | i.mu.Unlock() | |
| 706 | + | } | |
| 707 | + | func (i *Interactive) stripWebSearchTool() { | |
| 708 | + | // Revoke first so a stale tool snapshotted by core cannot execute while | |
| 709 | + | // the advertised registry is being replaced. | |
| 710 | + | i.setWebSearchAvailable(false) | |
| 711 | + | i.agentMu.Lock() | |
| 712 | + | defer i.agentMu.Unlock() | |
| 713 | + | i.mu.Lock() | |
| 714 | + | defer i.mu.Unlock() | |
| 715 | + | if i.agent == nil { | |
| 716 | + | return | |
| 717 | + | } | |
| 718 | + | tools := i.agent.ToolsSnapshot() | |
| 719 | + | if _, ok := tools["web_search"]; !ok { | |
| 720 | + | return | |
| 721 | + | } | |
| 722 | + | delete(tools, "web_search") | |
| 723 | + | i.agent.SetTools(tools) | |
| 724 | + | } | |
| 725 | + | func (i *Interactive) applySettingToggle(key string, value bool) { | |
| 726 | + | // Every setting toggle forces a full repaint at the end — same | |
| 727 | + | // effect as the user pressing Ctrl+L — so any per-setting visual | |
| 728 | + | // change (image rendering, status copy, future toggles) lands | |
| 729 | + | // immediately instead of waiting for the next diff frame. | |
| 730 | + | defer func() { | |
| 731 | + | i.requestRendererClear() | |
| 732 | + | i.invalidate() | |
| 733 | + | }() | |
| 734 | + | switch key { | |
| 735 | + | case "inline_images_enabled": | |
| 736 | + | val := value | |
| 737 | + | i.cfg.InlineImagesEnabled = &val | |
| 738 | + | if i.cfg.SettingsStore != nil { | |
| 739 | + | if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil { | |
| 740 | + | i.mu.Lock() | |
| 741 | + | i.statusErr = "settings: " + err.Error() | |
| 742 | + | i.mu.Unlock() | |
| 743 | + | return | |
| 744 | + | } | |
| 745 | + | } | |
| 746 | + | i.mu.Lock() | |
| 747 | + | i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled) | |
| 748 | + | i.view.InvalidateRenderCache() | |
| 749 | + | i.statusOK = "inline image rendering " + onOff(value) | |
| 750 | + | i.statusErr = "" | |
| 751 | + | i.mu.Unlock() | |
| 752 | + | case "terminal_alerts_enabled": | |
| 753 | + | val := value | |
| 754 | + | i.mu.Lock() | |
| 755 | + | i.cfg.TerminalAlertsEnabled = &val | |
| 756 | + | i.mu.Unlock() | |
| 757 | + | if store, ok := i.cfg.SettingsStore.(terminalAlertsSettingsStore); ok { | |
| 758 | + | if err := store.SetTerminalAlertsEnabled(value); err != nil { | |
| 759 | + | i.mu.Lock() | |
| 760 | + | i.statusErr = "settings: " + err.Error() | |
| 761 | + | i.mu.Unlock() | |
| 762 | + | return | |
| 763 | + | } | |
| 764 | + | } | |
| 765 | + | i.mu.Lock() | |
| 766 | + | i.statusOK = "terminal alerts " + onOff(value) | |
| 767 | + | i.statusErr = "" | |
| 768 | + | i.mu.Unlock() | |
| 769 | + | case "terminal_title_enabled": | |
| 770 | + | val := value | |
| 771 | + | var cancel context.CancelFunc | |
| 772 | + | i.mu.Lock() | |
| 773 | + | i.cfg.TerminalTitleEnabled = &val | |
| 774 | + | if value { | |
| 775 | + | i.writeTerminalTitleLocked(i.sessionTitle) | |
| 776 | + | } else { | |
| 777 | + | cancel = i.titleCancel | |
| 778 | + | i.titleCancel = nil | |
| 779 | + | i.titleVersion++ | |
| 780 | + | i.writeTerminalTitleLocked("") | |
| 781 | + | } | |
| 782 | + | i.mu.Unlock() | |
| 783 | + | if cancel != nil { | |
| 784 | + | cancel() | |
| 785 | + | } | |
| 786 | + | if store, ok := i.cfg.SettingsStore.(terminalTitleSettingsStore); ok { | |
| 787 | + | if err := store.SetTerminalTitleEnabled(value); err != nil { | |
| 788 | + | i.mu.Lock() | |
| 789 | + | i.statusErr = "settings: " + err.Error() | |
| 790 | + | i.mu.Unlock() | |
| 791 | + | return | |
| 792 | + | } | |
| 793 | + | } | |
| 794 | + | i.mu.Lock() | |
| 795 | + | i.statusOK = "AI terminal titles " + onOff(value) | |
| 796 | + | i.statusErr = "" | |
| 797 | + | i.mu.Unlock() | |
| 798 | + | case "web_search_enabled": | |
| 799 | + | apply := func() { i.applyWebSearchSettingToggle(key, value) } | |
| 800 | + | if i.cfg.SessionTransition != nil { | |
| 801 | + | i.cfg.SessionTransition(apply) | |
| 802 | + | } else { | |
| 803 | + | apply() | |
| 804 | + | } | |
| 805 | + | case "ponytail_enabled": | |
| 806 | + | previous := i.ponytailEnabled() | |
| 807 | + | store, available := i.cfg.SettingsStore.(ponytailSettingsStore) | |
| 808 | + | if !available || i.cfg.RefreshPrompt == nil { | |
| 809 | + | i.resetSettingsToggle(key, previous) | |
| 810 | + | i.mu.Lock() | |
| 811 | + | i.statusOK = "" | |
| 812 | + | i.statusErr = "ponytail coding mode unavailable: persistent settings and live prompt refresh are required" | |
| 813 | + | i.mu.Unlock() | |
| 814 | + | return | |
| 815 | + | } | |
| 816 | + | if err := store.SetPonytailEnabled(value); err != nil { | |
| 817 | + | i.resetSettingsToggle(key, previous) | |
| 818 | + | i.mu.Lock() | |
| 819 | + | i.statusOK = "" | |
| 820 | + | i.statusErr = "settings: " + err.Error() | |
| 821 | + | i.mu.Unlock() | |
| 822 | + | return | |
| 823 | + | } | |
| 824 | + | val := value | |
| 825 | + | i.cfg.PonytailEnabled = &val | |
| 826 | + | if err := i.cfg.RefreshPrompt(); err != nil { | |
| 827 | + | errMsg := "settings: ponytail prompt refresh: " + err.Error() | |
| 828 | + | if rollbackErr := store.SetPonytailEnabled(previous); rollbackErr != nil { | |
| 829 | + | // The first write succeeded, so keep the in-memory setting at | |
| 830 | + | // the value that remains durable instead of claiming that the | |
| 831 | + | // rollback took effect. | |
| 832 | + | i.resetSettingsToggle(key, value) | |
| 833 | + | errMsg += "; rollback persistence: " + rollbackErr.Error() | |
| 834 | + | if reconcileErr := i.cfg.RefreshPrompt(); reconcileErr != nil { | |
| 835 | + | errMsg += "; durable-state refresh: " + reconcileErr.Error() | |
| 836 | + | } | |
| 837 | + | } else { | |
| 838 | + | previousVal := previous | |
| 839 | + | i.cfg.PonytailEnabled = &previousVal | |
| 840 | + | i.resetSettingsToggle(key, previous) | |
| 841 | + | if refreshErr := i.cfg.RefreshPrompt(); refreshErr != nil { | |
| 842 | + | errMsg += "; rollback refresh: " + refreshErr.Error() | |
| 843 | + | } | |
| 844 | + | } | |
| 845 | + | i.mu.Lock() | |
| 846 | + | i.statusOK = "" | |
| 847 | + | i.statusErr = errMsg | |
| 848 | + | i.mu.Unlock() | |
| 849 | + | return | |
| 850 | + | } | |
| 851 | + | i.mu.Lock() | |
| 852 | + | i.statusOK = "ponytail coding mode " + onOff(value) | |
| 853 | + | i.statusErr = "" | |
| 854 | + | i.mu.Unlock() | |
| 855 | + | case "auto_subagents_enabled": | |
| 856 | + | if value && !i.autoSubagentsAvailable() { | |
| 857 | + | i.mu.Lock() | |
| 858 | + | i.statusOK = "" | |
| 859 | + | i.statusErr = "subagent orchestrator unavailable: " + i.autoSubagentsUnavailableHint() | |
| 860 | + | i.mu.Unlock() | |
| 861 | + | return | |
| 862 | + | } | |
| 863 | + | i.mu.Lock() | |
| 864 | + | previousFlag := i.cfg.AutoSubagentsEnabled | |
| 865 | + | previous := previousFlag != nil && *previousFlag | |
| 866 | + | val := value | |
| 867 | + | i.cfg.AutoSubagentsEnabled = &val | |
| 868 | + | i.mu.Unlock() | |
| 869 | + | if i.cfg.SettingsStore != nil { | |
| 870 | + | if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil { | |
| 871 | + | i.mu.Lock() | |
| 872 | + | i.cfg.AutoSubagentsEnabled = previousFlag | |
| 873 | + | i.statusOK = "" | |
| 874 | + | i.statusErr = "settings: " + err.Error() | |
| 875 | + | i.mu.Unlock() | |
| 876 | + | i.resetSettingsToggle(key, previous) | |
| 877 | + | return | |
| 878 | + | } | |
| 879 | + | } | |
| 880 | + | // The subagent tools remain available for explicit user requests. | |
| 881 | + | // Swap only the system-prompt guidance between strict orchestration and | |
| 882 | + | // on-demand delegation. | |
| 883 | + | i.applyAutoSubagentsTool() | |
| 884 | + | // Also swap the system-prompt addendum so the model knows whether it | |
| 885 | + | // should orchestrate proactively or delegate only on user request. | |
| 886 | + | i.applyAutoSubagentsSystemPrompt(value) | |
| 887 | + | i.mu.Lock() | |
| 888 | + | i.statusOK = "subagent orchestrator " + onOff(value) | |
| 889 | + | i.statusErr = "" | |
| 890 | + | i.mu.Unlock() | |
| 891 | + | case "fast_mode": | |
| 892 | + | previous := i.cfg.FastMode != nil && *i.cfg.FastMode | |
| 893 | + | if err := provider.ValidateFastMode(i.cfg.Provider, value); err != nil { | |
| 894 | + | i.resetSettingsToggle(key, previous) | |
| 895 | + | i.mu.Lock() | |
| 896 | + | i.statusErr = "settings: " + err.Error() | |
| 897 | + | i.mu.Unlock() | |
| 898 | + | return | |
| 899 | + | } | |
| 900 | + | if store, ok := i.cfg.SettingsStore.(fastModeSettingsStore); ok { | |
| 901 | + | if err := store.SetFastMode(value); err != nil { | |
| 902 | + | i.resetSettingsToggle(key, previous) | |
| 903 | + | i.mu.Lock() | |
| 904 | + | i.statusErr = "settings: " + err.Error() | |
| 905 | + | i.mu.Unlock() | |
| 906 | + | return | |
| 907 | + | } | |
| 908 | + | } | |
| 909 | + | val := value | |
| 910 | + | i.cfg.FastMode = &val | |
| 911 | + | if i.agent != nil { | |
| 912 | + | i.agent.SetFastMode(value) | |
| 913 | + | } | |
| 914 | + | i.mu.Lock() | |
| 915 | + | i.statusOK = "fast mode " + onOff(value) | |
| 916 | + | i.statusErr = "" | |
| 917 | + | i.mu.Unlock() | |
| 918 | + | case "lsp_enabled": | |
| 919 | + | previous := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled | |
| 920 | + | store, hasStore := i.cfg.SettingsStore.(lspSettingsStore) | |
| 921 | + | if hasStore { | |
| 922 | + | if err := store.SetLSPEnabled(value); err != nil { | |
| 923 | + | i.mu.Lock() | |
| 924 | + | i.statusErr = "settings: " + err.Error() | |
| 925 | + | i.mu.Unlock() | |
| 926 | + | return | |
| 927 | + | } | |
| 928 | + | } | |
| 929 | + | val := value | |
| 930 | + | i.cfg.LSPEnabled = &val | |
| 931 | + | if i.cfg.RefreshTools != nil { | |
| 932 | + | if err := i.cfg.RefreshTools(); err != nil { | |
| 933 | + | errMsg := "settings: refresh tools: " + err.Error() | |
| 934 | + | rollbackErr := error(nil) | |
| 935 | + | if hasStore { | |
| 936 | + | rollbackErr = store.SetLSPEnabled(previous) | |
| 937 | + | } | |
| 938 | + | if rollbackErr != nil { | |
| 939 | + | // The first write succeeded, so keep the in-memory setting at | |
| 940 | + | // the value that remains durable instead of claiming that the | |
| 941 | + | // rollback took effect. | |
| 942 | + | errMsg += "; rollback persistence: " + rollbackErr.Error() | |
| 943 | + | if reconcileErr := i.cfg.RefreshTools(); reconcileErr != nil { | |
| 944 | + | errMsg += "; durable-state refresh: " + reconcileErr.Error() | |
| 945 | + | } | |
| 946 | + | } else { | |
| 947 | + | previousVal := previous | |
| 948 | + | i.cfg.LSPEnabled = &previousVal | |
| 949 | + | if refreshErr := i.cfg.RefreshTools(); refreshErr != nil { | |
| 950 | + | errMsg += "; rollback refresh: " + refreshErr.Error() | |
| 951 | + | } | |
| 952 | + | } | |
| 953 | + | i.mu.Lock() | |
| 954 | + | i.statusOK = "" | |
| 955 | + | i.statusErr = errMsg | |
| 956 | + | i.mu.Unlock() | |
| 957 | + | return | |
| 958 | + | } | |
| 959 | + | } | |
| 960 | + | i.mu.Lock() | |
| 961 | + | i.statusOK = "lsp in main session " + onOff(value) | |
| 962 | + | i.statusErr = "" | |
| 963 | + | i.mu.Unlock() | |
| 964 | + | case "subagent_lsp_enabled": | |
| 965 | + | if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok { | |
| 966 | + | if err := store.SetSubagentLSPEnabled(value); err != nil { | |
| 967 | + | i.mu.Lock() | |
| 968 | + | i.statusErr = "settings: " + err.Error() | |
| 969 | + | i.mu.Unlock() | |
| 970 | + | return | |
| 971 | + | } | |
| 972 | + | } | |
| 973 | + | val := value | |
| 974 | + | i.cfg.SubagentLSPEnabled = &val | |
| 975 | + | i.mu.Lock() | |
| 976 | + | i.statusOK = "lsp in sub-agents " + onOff(value) | |
| 977 | + | i.statusErr = "" | |
| 978 | + | i.mu.Unlock() | |
| 979 | + | case "jail_by_default": | |
| 980 | + | val := value | |
| 981 | + | i.cfg.JailByDefault = &val | |
| 982 | + | if i.cfg.SettingsStore != nil { | |
| 983 | + | if err := i.cfg.SettingsStore.SetJailByDefault(value); err != nil { | |
| 984 | + | i.mu.Lock() | |
| 985 | + | i.statusErr = "settings: " + err.Error() | |
| 986 | + | i.mu.Unlock() | |
| 987 | + | return | |
| 988 | + | } | |
| 989 | + | } | |
| 990 | + | if i.cfg.Sandbox != nil { | |
| 991 | + | if value { | |
| 992 | + | i.cfg.Sandbox.Lock() | |
| 993 | + | } else { | |
| 994 | + | i.cfg.Sandbox.Unlock() | |
| 995 | + | } | |
| 996 | + | } | |
| 997 | + | i.mu.Lock() | |
| 998 | + | i.statusOK = "jail by default " + onOff(value) | |
| 999 | + | i.statusErr = "" | |
| 1000 | + | i.mu.Unlock() | |
| 1001 | + | case "recursive_file_suggest": | |
| 1002 | + | val := value | |
| 1003 | + | i.cfg.RecursiveFileSuggest = &val | |
| 1004 | + | if i.cfg.SettingsStore != nil { | |
| 1005 | + | if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil { | |
| 1006 | + | i.mu.Lock() | |
| 1007 | + | i.statusErr = "settings: " + err.Error() | |
| 1008 | + | i.mu.Unlock() | |
| 1009 | + | return | |
| 1010 | + | } | |
| 1011 | + | } | |
| 1012 | + | // Flip the live picker so the next @ reflects the new mode | |
| 1013 | + | // without restarting zut. SetRecursive drops its cache. | |
| 1014 | + | i.fileSuggest.SetRecursive(value) | |
| 1015 | + | i.mu.Lock() | |
| 1016 | + | i.statusOK = "recursive @-file search " + onOff(value) | |
| 1017 | + | i.statusErr = "" | |
| 1018 | + | i.mu.Unlock() | |
| 1019 | + | case "respect_gitignore": | |
| 1020 | + | val := value | |
| 1021 | + | i.cfg.RespectGitignore = &val | |
| 1022 | + | if i.cfg.SettingsStore != nil { | |
| 1023 | + | if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil { | |
| 1024 | + | i.mu.Lock() | |
| 1025 | + | i.statusErr = "settings: " + err.Error() | |
| 1026 | + | i.mu.Unlock() | |
| 1027 | + | return | |
| 1028 | + | } | |
| 1029 | + | } | |
| 1030 | + | i.fileSuggest.SetRespectGitignore(value) | |
| 1031 | + | i.mu.Lock() | |
| 1032 | + | i.statusOK = "hide gitignored files in @-picker " + onOff(value) | |
| 1033 | + | i.statusErr = "" | |
| 1034 | + | i.mu.Unlock() | |
| 1035 | + | case "compact_mode": | |
| 1036 | + | val := value | |
| 1037 | + | i.cfg.CompactMode = &val | |
| 1038 | + | if i.cfg.SettingsStore != nil { | |
| 1039 | + | if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil { | |
| 1040 | + | i.mu.Lock() | |
| 1041 | + | i.statusErr = "settings: " + err.Error() | |
| 1042 | + | i.mu.Unlock() | |
| 1043 | + | return | |
| 1044 | + | } | |
| 1045 | + | } | |
| 1046 | + | i.mu.Lock() | |
| 1047 | + | i.view.CompactMode = value | |
| 1048 | + | i.view.InvalidateRenderCache() | |
| 1049 | + | i.statusOK = "compact transcript rendering " + onOff(value) | |
| 1050 | + | i.statusErr = "" | |
| 1051 | + | i.mu.Unlock() | |
| 1052 | + | case "show_instructions_at_startup": | |
| 1053 | + | val := value | |
| 1054 | + | i.cfg.ShowInstructionsAtStartup = &val | |
| 1055 | + | if store, ok := i.cfg.SettingsStore.(showInstructionsSettingsStore); ok { | |
| 1056 | + | if err := store.SetShowInstructionsAtStartup(value); err != nil { | |
| 1057 | + | i.mu.Lock() | |
| 1058 | + | i.statusErr = "settings: " + err.Error() | |
| 1059 | + | i.mu.Unlock() | |
| 1060 | + | return | |
| 1061 | + | } | |
| 1062 | + | } | |
| 1063 | + | i.mu.Lock() | |
| 1064 | + | i.view.StartupAgentName = "" | |
| 1065 | + | i.view.StartupContextPaths = nil | |
| 1066 | + | i.view.StartupExtensionNames = nil | |
| 1067 | + | i.view.StartupSkillNames = nil | |
| 1068 | + | i.view.InvalidateRenderCache() | |
| 1069 | + | if value { | |
| 1070 | + | i.view.StartupAgentName = i.cfg.StartupAgentName | |
| 1071 | + | i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...) | |
| 1072 | + | i.view.StartupExtensionNames = append(i.view.StartupExtensionNames, i.cfg.StartupExtensionNames...) | |
| 1073 | + | i.view.StartupSkillNames = append(i.view.StartupSkillNames, i.cfg.StartupSkillNames...) | |
| 1074 | + | } | |
| 1075 | + | i.statusOK = "show loaded resources at startup " + onOff(value) | |
| 1076 | + | i.statusErr = "" | |
| 1077 | + | i.mu.Unlock() | |
| 1078 | + | } | |
| 1079 | + | } | |
| 1080 | + | func (i *Interactive) applyTUIInputStyleSetting(style string) { | |
| 1081 | + | defer func() { | |
| 1082 | + | i.requestRendererClear() | |
| 1083 | + | i.invalidate() | |
| 1084 | + | }() | |
| 1085 | + | style = tui.NormalizeInputStyle(style) | |
| 1086 | + | i.cfg.TUIInputStyle = style | |
| 1087 | + | i.applyInputCursorColor() | |
| 1088 | + | if i.cfg.SettingsStore != nil { | |
| 1089 | + | if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil { | |
| 1090 | + | i.mu.Lock() | |
| 1091 | + | i.statusErr = "settings: " + err.Error() | |
| 1092 | + | i.mu.Unlock() | |
| 1093 | + | return | |
| 1094 | + | } | |
| 1095 | + | } | |
| 1096 | + | i.mu.Lock() | |
| 1097 | + | i.statusOK = "input style " + style | |
| 1098 | + | i.statusErr = "" | |
| 1099 | + | i.mu.Unlock() | |
| 1100 | + | } | |
| 1101 | + | func (i *Interactive) applyTUIStatusPositionSetting(position string) { | |
| 1102 | + | defer func() { | |
| 1103 | + | i.requestRendererClear() | |
| 1104 | + | i.invalidate() | |
| 1105 | + | }() | |
| 1106 | + | position = tui.NormalizeStatusPosition(position) | |
| 1107 | + | i.cfg.TUIStatusPosition = position | |
| 1108 | + | if i.cfg.SettingsStore != nil { | |
| 1109 | + | if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil { | |
| 1110 | + | i.mu.Lock() | |
| 1111 | + | i.statusErr = "settings: " + err.Error() | |
| 1112 | + | i.mu.Unlock() | |
| 1113 | + | return | |
| 1114 | + | } | |
| 1115 | + | } | |
| 1116 | + | i.mu.Lock() | |
| 1117 | + | label := "above input" | |
| 1118 | + | if position == tui.StatusPositionBelowInput { | |
| 1119 | + | label = "below input" | |
| 1120 | + | } | |
| 1121 | + | i.statusOK = "status position " + label | |
| 1122 | + | i.statusErr = "" | |
| 1123 | + | i.mu.Unlock() | |
| 1124 | + | } | |
| 1125 | + | func (i *Interactive) applyTUIWorkingPositionSetting(position string) { | |
| 1126 | + | defer func() { | |
| 1127 | + | i.requestRendererClear() | |
| 1128 | + | i.invalidate() | |
| 1129 | + | }() | |
| 1130 | + | position = tui.NormalizeWorkingPosition(position) | |
| 1131 | + | i.cfg.TUIWorkingPosition = position | |
| 1132 | + | if i.cfg.SettingsStore != nil { | |
| 1133 | + | if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil { | |
| 1134 | + | i.mu.Lock() | |
| 1135 | + | i.statusErr = "settings: " + err.Error() | |
| 1136 | + | i.mu.Unlock() | |
| 1137 | + | return | |
| 1138 | + | } | |
| 1139 | + | } | |
| 1140 | + | i.mu.Lock() | |
| 1141 | + | label := "above input" | |
| 1142 | + | if position == tui.WorkingPositionBelowInput { | |
| 1143 | + | label = "below input" | |
| 1144 | + | } | |
| 1145 | + | i.statusOK = "working spinner position " + label | |
| 1146 | + | i.statusErr = "" | |
| 1147 | + | i.mu.Unlock() | |
| 1148 | + | } | |
| 1149 | + | func (i *Interactive) applyThemeSetting(name string) { | |
| 1150 | + | if i.cfg.SettingsStore != nil { | |
| 1151 | + | if err := i.cfg.SettingsStore.SetTheme(name); err != nil { | |
| 1152 | + | i.mu.Lock() | |
| 1153 | + | i.statusErr = "settings: " + err.Error() | |
| 1154 | + | i.mu.Unlock() | |
| 1155 | + | return | |
| 1156 | + | } | |
| 1157 | + | } | |
| 1158 | + | i.cfg.ThemeName = name | |
| 1159 | + | if name == "auto" { | |
| 1160 | + | i.cfg.ThemeName = "" | |
| 1161 | + | } | |
| 1162 | + | i.applyThemeNow(name) | |
| 1163 | + | } | |
| 1164 | + | func (i *Interactive) applyThemeNow(name string) { | |
| 1165 | + | if name == "" { | |
| 1166 | + | name = "auto" | |
| 1167 | + | } | |
| 1168 | + | detected := tui.Dark | |
| 1169 | + | if tui.IsLightTheme(i.cfg.Theme) { | |
| 1170 | + | detected = tui.Light | |
| 1171 | + | } | |
| 1172 | + | // Keep the startup terminal snapshot available when a user switches | |
| 1173 | + | // themes live; inherited mode must not issue OSC queries while the TUI | |
| 1174 | + | // already owns raw stdin. | |
| 1175 | + | detected.Terminal = i.cfg.Theme.Terminal | |
| 1176 | + | th, applied, err := tui.LoadThemeFromHome(i.cfg.ZutHome, name, detected) | |
| 1177 | + | if err != nil { | |
| 1178 | + | if i.cfg.SettingsStore != nil { | |
| 1179 | + | _ = i.cfg.SettingsStore.SetTheme("auto") | |
| 1180 | + | } | |
| 1181 | + | i.cfg.ThemeName = "" | |
| 1182 | + | th, _, _ = tui.LoadThemeFromHome(i.cfg.ZutHome, "auto", detected) | |
| 1183 | + | i.mu.Lock() | |
| 1184 | + | i.statusErr = "theme missing; reset to default" | |
| 1185 | + | i.mu.Unlock() | |
| 1186 | + | } else { | |
| 1187 | + | i.mu.Lock() | |
| 1188 | + | label := applied | |
| 1189 | + | if label == "" { | |
| 1190 | + | label = "auto" | |
| 1191 | + | } | |
| 1192 | + | i.statusOK = "theme " + label | |
| 1193 | + | i.statusErr = "" | |
| 1194 | + | i.mu.Unlock() | |
| 1195 | + | } | |
| 1196 | + | i.mu.Lock() | |
| 1197 | + | i.cfg.Theme = th | |
| 1198 | + | i.view.Theme = th | |
| 1199 | + | i.view.InvalidateRenderCache() | |
| 1200 | + | i.mu.Unlock() | |
| 1201 | + | i.ed.Prompt = th.AccentBar(th.Accent) | |
| 1202 | + | i.applyInputCursorColor() | |
| 1203 | + | i.spin.Configure(th) | |
| 1204 | + | i.requestRendererTheme(th) | |
| 1205 | + | i.requestRendererClear() | |
| 1206 | + | i.invalidate() | |
| 1207 | + | } | |
| 1208 | + | func (i *Interactive) applyReasoningSetting(level string) { | |
| 1209 | + | defer func() { | |
| 1210 | + | i.requestRendererClear() | |
| 1211 | + | i.invalidate() | |
| 1212 | + | }() | |
| 1213 | + | level = provider.NormalizeReasoning(level) | |
| 1214 | + | i.cfg.Reasoning = level | |
| 1215 | + | if i.cfg.SettingsStore != nil { | |
| 1216 | + | if err := i.cfg.SettingsStore.SetReasoning(level); err != nil { | |
| 1217 | + | i.mu.Lock() | |
| 1218 | + | i.statusErr = "settings: " + err.Error() | |
| 1219 | + | i.mu.Unlock() | |
| 1220 | + | return | |
| 1221 | + | } | |
| 1222 | + | } | |
| 1223 | + | if i.cfg.OnReasoningChanged != nil { | |
| 1224 | + | i.cfg.OnReasoningChanged(level) | |
| 1225 | + | } | |
| 1226 | + | i.mu.Lock() | |
| 1227 | + | if i.agent != nil { | |
| 1228 | + | i.agent.Reasoning = level | |
| 1229 | + | } | |
| 1230 | + | label := level | |
| 1231 | + | if label == "" { | |
| 1232 | + | label = "off" | |
| 1233 | + | } | |
| 1234 | + | i.statusOK = "reasoning level " + label | |
| 1235 | + | i.statusErr = "" | |
| 1236 | + | i.mu.Unlock() | |
| 1237 | + | } | |
A packages/agent/modes/interactive_submit.go +61 -0
| @@ -0,0 +1,61 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import "context" | |
| 4 | + | ||
| 5 | + | // Submit feeds text through the agent loop as if the user had typed it. | |
| 6 | + | func (i *Interactive) Submit(text string) { | |
| 7 | + | if cmd, ok := shellEscapeCommand(text); ok { | |
| 8 | + | i.startShellEscape(i.runCtx, cmd) | |
| 9 | + | return | |
| 10 | + | } | |
| 11 | + | parent := i.runCtx | |
| 12 | + | if parent == nil { | |
| 13 | + | parent = context.Background() | |
| 14 | + | } | |
| 15 | + | i.mu.Lock() | |
| 16 | + | awaitingStartupPre := i.awaitingStartupPre | |
| 17 | + | i.mu.Unlock() | |
| 18 | + | if !awaitingStartupPre { | |
| 19 | + | i.maybeStartSessionTitle(parent, text) | |
| 20 | + | } | |
| 21 | + | i.startTurn(parent, text) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // completeStartupPre applies deferred InitialInput after entry.pre finishes. | |
| 25 | + | // When AutoSubmitInitial was set, the deferred prompt is submitted; otherwise | |
| 26 | + | // it only pre-fills the editor (CLI-supplied prompts). | |
| 27 | + | func (i *Interactive) completeStartupPre() { | |
| 28 | + | i.mu.Lock() | |
| 29 | + | if !i.awaitingStartupPre { | |
| 30 | + | i.mu.Unlock() | |
| 31 | + | return | |
| 32 | + | } | |
| 33 | + | i.awaitingStartupPre = false | |
| 34 | + | deferred := i.deferredInitialInput | |
| 35 | + | auto := i.autoSubmitDeferred | |
| 36 | + | i.deferredInitialInput = "" | |
| 37 | + | i.autoSubmitDeferred = false | |
| 38 | + | onDone := i.cfg.OnStartupPreDone | |
| 39 | + | i.mu.Unlock() | |
| 40 | + | if onDone != nil { | |
| 41 | + | onDone() | |
| 42 | + | } | |
| 43 | + | i.startupPreDone <- startupPreResult{deferred: deferred, autoSubmit: auto} | |
| 44 | + | i.invalidate() | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // applyStartupPreResult runs on the TUI event loop so the editor remains | |
| 48 | + | // single-threaded. Input entered while resources were reloading wins over the | |
| 49 | + | // deferred prefill rather than being overwritten. | |
| 50 | + | func (i *Interactive) applyStartupPreResult(result startupPreResult) { | |
| 51 | + | if result.deferred == "" { | |
| 52 | + | return | |
| 53 | + | } | |
| 54 | + | if result.autoSubmit { | |
| 55 | + | i.Submit(result.deferred) | |
| 56 | + | return | |
| 57 | + | } | |
| 58 | + | if i.ed.IsEmpty() { | |
| 59 | + | i.ed.SetValue(result.deferred) | |
| 60 | + | } | |
| 61 | + | } | |
A packages/agent/modes/interactive_telegram.go +198 -0
| @@ -0,0 +1,198 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zut/packages/agent/modes/telegram" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func (i *Interactive) openTelegramDialog() { | |
| 12 | + | items := i.telegramMenuItems() | |
| 13 | + | if len(items) == 0 { | |
| 14 | + | i.mu.Lock() | |
| 15 | + | i.statusErr = "telegram not configured. run `zut telegram-bot setup` first." | |
| 16 | + | i.mu.Unlock() | |
| 17 | + | i.invalidate() | |
| 18 | + | return | |
| 19 | + | } | |
| 20 | + | i.telegramDialog.Open(items) | |
| 21 | + | i.invalidate() | |
| 22 | + | } | |
| 23 | + | func (i *Interactive) telegramMenuItems() []telegramItem { | |
| 24 | + | cfg, err := telegram.LoadConfig(i.cfg.ZutHome) | |
| 25 | + | if err != nil || cfg.BotToken == "" { | |
| 26 | + | return nil | |
| 27 | + | } | |
| 28 | + | var items []telegramItem | |
| 29 | + | if i.telegramBridge != nil && i.telegramBridge.Active() { | |
| 30 | + | items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"}) | |
| 31 | + | st := i.telegramBridge.State() | |
| 32 | + | hint := "active" | |
| 33 | + | if st.Username != "" { | |
| 34 | + | hint += " as @" + st.Username | |
| 35 | + | } | |
| 36 | + | items = append(items, telegramItem{label: "status", action: "status", hint: hint}) | |
| 37 | + | } else { | |
| 38 | + | label := "connect" | |
| 39 | + | hint := "start mirroring dms into this session" | |
| 40 | + | if cfg.AllowedUserID == 0 { | |
| 41 | + | hint = "awaiting pairing (send /start to the bot once connected)" | |
| 42 | + | } | |
| 43 | + | items = append(items, telegramItem{label: label, action: "connect", hint: hint}) | |
| 44 | + | items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"}) | |
| 45 | + | } | |
| 46 | + | return items | |
| 47 | + | } | |
| 48 | + | func (i *Interactive) doTelegram(action string) { | |
| 49 | + | switch action { | |
| 50 | + | case "connect": | |
| 51 | + | i.telegramConnect() | |
| 52 | + | case "disconnect": | |
| 53 | + | i.telegramDisconnect() | |
| 54 | + | case "status": | |
| 55 | + | i.telegramStatus() | |
| 56 | + | default: | |
| 57 | + | i.mu.Lock() | |
| 58 | + | i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)" | |
| 59 | + | i.mu.Unlock() | |
| 60 | + | i.invalidate() | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | func (i *Interactive) telegramConnect() { | |
| 64 | + | if i.telegramBridge != nil && i.telegramBridge.Active() { | |
| 65 | + | i.mu.Lock() | |
| 66 | + | i.statusOK = "telegram already connected" | |
| 67 | + | i.statusErr = "" | |
| 68 | + | i.mu.Unlock() | |
| 69 | + | i.invalidate() | |
| 70 | + | return | |
| 71 | + | } | |
| 72 | + | cfg, err := telegram.LoadConfig(i.cfg.ZutHome) | |
| 73 | + | if err != nil { | |
| 74 | + | i.mu.Lock() | |
| 75 | + | i.statusErr = "telegram: " + err.Error() | |
| 76 | + | i.mu.Unlock() | |
| 77 | + | i.invalidate() | |
| 78 | + | return | |
| 79 | + | } | |
| 80 | + | if cfg.BotToken == "" { | |
| 81 | + | i.mu.Lock() | |
| 82 | + | i.statusErr = "telegram: no bot token configured. run `zut telegram-bot setup` first." | |
| 83 | + | i.mu.Unlock() | |
| 84 | + | i.invalidate() | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | // Refuse to start when a background daemon is already polling | |
| 88 | + | // the same bot. Two concurrent long-poll consumers race each | |
| 89 | + | // update and one always loses, so DMs get half-delivered. The | |
| 90 | + | // user can `zut telegram-bot stop` first, then /telegram connect. | |
| 91 | + | if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 { | |
| 92 | + | i.mu.Lock() | |
| 93 | + | i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zut telegram-bot stop` first.", pid) | |
| 94 | + | i.mu.Unlock() | |
| 95 | + | i.invalidate() | |
| 96 | + | return | |
| 97 | + | } | |
| 98 | + | bridge := &telegram.Bridge{ | |
| 99 | + | Client: telegram.NewClient(cfg.BotToken), | |
| 100 | + | Config: cfg, | |
| 101 | + | Save: func(next telegram.Config) error { | |
| 102 | + | return telegram.SaveConfig(i.cfg.ZutHome, next) | |
| 103 | + | }, | |
| 104 | + | Host: &telegramHost{iv: i}, | |
| 105 | + | } | |
| 106 | + | i.mu.Lock() | |
| 107 | + | i.telegramBridge = bridge | |
| 108 | + | i.mu.Unlock() | |
| 109 | + | // Strip web_search before the bridge's startup handshake can accept a | |
| 110 | + | // Telegram update. ApplyAgentPromptConfig also keeps it stripped from | |
| 111 | + | // concurrent refreshes while this bridge pointer is attached. | |
| 112 | + | i.applyTelegramTools(true) | |
| 113 | + | if err := bridge.Start(i.runCtx); err != nil { | |
| 114 | + | i.mu.Lock() | |
| 115 | + | i.telegramBridge = nil | |
| 116 | + | i.mu.Unlock() | |
| 117 | + | refreshErr := i.refreshToolsAfterTelegram() | |
| 118 | + | i.mu.Lock() | |
| 119 | + | i.statusErr = "telegram connect failed: " + err.Error() | |
| 120 | + | if refreshErr != nil { | |
| 121 | + | i.statusErr += "; tool refresh: " + refreshErr.Error() | |
| 122 | + | } | |
| 123 | + | i.mu.Unlock() | |
| 124 | + | i.invalidate() | |
| 125 | + | return | |
| 126 | + | } | |
| 127 | + | state := bridge.State() | |
| 128 | + | label := "telegram connected" | |
| 129 | + | if state.Username != "" { | |
| 130 | + | label += " as @" + state.Username | |
| 131 | + | } | |
| 132 | + | if state.PairedID == 0 { | |
| 133 | + | label += " — send /start to the bot from your phone to claim it" | |
| 134 | + | } | |
| 135 | + | i.mu.Lock() | |
| 136 | + | i.statusOK = label | |
| 137 | + | i.statusErr = "" | |
| 138 | + | i.mu.Unlock() | |
| 139 | + | i.invalidate() | |
| 140 | + | } | |
| 141 | + | func (i *Interactive) telegramDisconnect() { | |
| 142 | + | if i.telegramBridge == nil || !i.telegramBridge.Active() { | |
| 143 | + | i.mu.Lock() | |
| 144 | + | i.statusOK = "telegram already disconnected" | |
| 145 | + | i.statusErr = "" | |
| 146 | + | i.mu.Unlock() | |
| 147 | + | i.invalidate() | |
| 148 | + | return | |
| 149 | + | } | |
| 150 | + | bridge := i.telegramBridge | |
| 151 | + | bridge.Stop() | |
| 152 | + | i.mu.Lock() | |
| 153 | + | // Clear the pointer before rebuilding the normal registry. This both | |
| 154 | + | // allows web_search back into the refresh and marks the bridge inactive | |
| 155 | + | // for any concurrent prompt-config commit. | |
| 156 | + | i.telegramBridge = nil | |
| 157 | + | i.mu.Unlock() | |
| 158 | + | refreshErr := i.refreshToolsAfterTelegram() | |
| 159 | + | i.mu.Lock() | |
| 160 | + | if refreshErr != nil { | |
| 161 | + | i.statusOK = "" | |
| 162 | + | i.statusErr = "telegram disconnect tool refresh: " + refreshErr.Error() | |
| 163 | + | } else { | |
| 164 | + | i.statusOK = "telegram disconnected" | |
| 165 | + | i.statusErr = "" | |
| 166 | + | } | |
| 167 | + | i.mu.Unlock() | |
| 168 | + | i.invalidate() | |
| 169 | + | } | |
| 170 | + | func (i *Interactive) refreshToolsAfterTelegram() error { | |
| 171 | + | if i.cfg.RefreshTools == nil { | |
| 172 | + | i.stripWebSearchTool() | |
| 173 | + | i.applyTelegramTools(false) | |
| 174 | + | return errors.New("live tool refresh is unavailable") | |
| 175 | + | } | |
| 176 | + | if err := i.cfg.RefreshTools(); err != nil { | |
| 177 | + | i.stripWebSearchTool() | |
| 178 | + | i.applyTelegramTools(false) | |
| 179 | + | return err | |
| 180 | + | } | |
| 181 | + | i.applyTelegramTools(false) | |
| 182 | + | return nil | |
| 183 | + | } | |
| 184 | + | func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error { | |
| 185 | + | if a.bridge == nil { | |
| 186 | + | return fmt.Errorf("telegram bridge is not connected") | |
| 187 | + | } | |
| 188 | + | return a.bridge.SendImage(ctx, path, caption) | |
| 189 | + | } | |
| 190 | + | func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error { | |
| 191 | + | if a.bridge == nil { | |
| 192 | + | return fmt.Errorf("telegram bridge is not connected") | |
| 193 | + | } | |
| 194 | + | return a.bridge.SendDocument(ctx, path, caption) | |
| 195 | + | } | |
| 196 | + | func (a telegramSenderAdapter) Active() bool { | |
| 197 | + | return a.bridge != nil && a.bridge.Active() | |
| 198 | + | } | |
A packages/agent/modes/interactive_turn.go +637 -0
| @@ -0,0 +1,637 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "strings" | |
| 7 | + | "unicode" | |
| 8 | + | "unicode/utf8" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zut/packages/agent/tools" | |
| 11 | + | "github.com/bnema/zut/packages/core" | |
| 12 | + | "github.com/bnema/zut/packages/provider" | |
| 13 | + | "github.com/bnema/zut/packages/tui" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func shellEscapeCommand(text string) (string, bool) { | |
| 17 | + | return ShellEscapeCommand(text) | |
| 18 | + | } | |
| 19 | + | func ShellEscapeCommand(text string) (string, bool) { | |
| 20 | + | trimmed := strings.TrimLeft(text, " \t") | |
| 21 | + | if !strings.HasPrefix(trimmed, "!") { | |
| 22 | + | return "", false | |
| 23 | + | } | |
| 24 | + | cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!")) | |
| 25 | + | if cmd == "" { | |
| 26 | + | return "", false | |
| 27 | + | } | |
| 28 | + | return cmd, true | |
| 29 | + | } | |
| 30 | + | func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |
| 31 | + | i.mu.Lock() | |
| 32 | + | if i.busy || i.shellRunning { | |
| 33 | + | i.statusErr = "busy — wait for the current turn to finish before running a shell command" | |
| 34 | + | i.statusOK = "" | |
| 35 | + | i.mu.Unlock() | |
| 36 | + | i.invalidate() | |
| 37 | + | return | |
| 38 | + | } | |
| 39 | + | if parent == nil { | |
| 40 | + | parent = i.runCtx | |
| 41 | + | } | |
| 42 | + | if parent == nil { | |
| 43 | + | parent = context.Background() | |
| 44 | + | } | |
| 45 | + | ctx, cancel := context.WithCancel(parent) | |
| 46 | + | i.busy = true | |
| 47 | + | i.shellRunning = true | |
| 48 | + | i.shellLive = "$ " + cmd + "\n\n" | |
| 49 | + | i.cancelTurn = cancel | |
| 50 | + | i.statusErr = "" | |
| 51 | + | i.statusOK = "" | |
| 52 | + | i.spin.Start() | |
| 53 | + | i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}} | |
| 54 | + | // Clear stale extension notes the same way a new turn would so the | |
| 55 | + | // screen doesn't accumulate transient state. | |
| 56 | + | i.extNotes = nil | |
| 57 | + | i.scrollOffset = 0 | |
| 58 | + | i.parkedTurn = 0 | |
| 59 | + | i.parkedTotal = 0 | |
| 60 | + | i.helpBlock = nil | |
| 61 | + | sandbox := i.cfg.Sandbox | |
| 62 | + | cwd := i.cfg.CWD | |
| 63 | + | i.mu.Unlock() | |
| 64 | + | i.invalidate() | |
| 65 | + | ||
| 66 | + | go func() { | |
| 67 | + | defer cancel() | |
| 68 | + | raw, _ := json.Marshal(map[string]any{ | |
| 69 | + | "command": cmd, | |
| 70 | + | "timeout": ShellEscapeTimeoutSeconds, | |
| 71 | + | }) | |
| 72 | + | bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox} | |
| 73 | + | progress := func(chunk string) { | |
| 74 | + | i.mu.Lock() | |
| 75 | + | i.shellLive += chunk | |
| 76 | + | i.mu.Unlock() | |
| 77 | + | i.invalidate() | |
| 78 | + | } | |
| 79 | + | res, err := bash.Execute(ctx, raw, progress) | |
| 80 | + | ||
| 81 | + | var out string | |
| 82 | + | if err != nil { | |
| 83 | + | out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]" | |
| 84 | + | } else { | |
| 85 | + | for _, c := range res.Content { | |
| 86 | + | if tb, ok := c.(provider.TextBlock); ok { | |
| 87 | + | out += tb.Text | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | cancelled := ctx.Err() != nil | |
| 92 | + | failed := err != nil || res.IsError || cancelled | |
| 93 | + | if cancelled { | |
| 94 | + | out += "\n\n[cancelled]" | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | if i.agent != nil { | |
| 98 | + | i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"}) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | i.mu.Lock() | |
| 102 | + | i.shellRunning = false | |
| 103 | + | i.shellLive = "" | |
| 104 | + | i.busy = false | |
| 105 | + | i.cancelTurn = nil | |
| 106 | + | pendingIdleWork := i.takePendingIdleWorkLocked() | |
| 107 | + | awaitingPre := i.awaitingStartupPre | |
| 108 | + | if failed { | |
| 109 | + | if cancelled { | |
| 110 | + | i.statusErr = "shell command cancelled" | |
| 111 | + | } else { | |
| 112 | + | i.statusErr = "shell command failed" | |
| 113 | + | } | |
| 114 | + | i.statusOK = "" | |
| 115 | + | } else { | |
| 116 | + | i.statusOK = "shell command finished" | |
| 117 | + | i.statusErr = "" | |
| 118 | + | } | |
| 119 | + | i.mu.Unlock() | |
| 120 | + | runPendingIdleWork(pendingIdleWork) | |
| 121 | + | i.invalidate() | |
| 122 | + | if awaitingPre { | |
| 123 | + | i.completeStartupPre() | |
| 124 | + | } | |
| 125 | + | }() | |
| 126 | + | } | |
| 127 | + | func (i *Interactive) startTurn(parent context.Context, prompt string) { | |
| 128 | + | i.startTurnWithImages(parent, prompt, nil) | |
| 129 | + | } | |
| 130 | + | func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) { | |
| 131 | + | i.startTurnRequest(parent, prompt, images, false, false) | |
| 132 | + | } | |
| 133 | + | func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) { | |
| 134 | + | if i.agent == nil { | |
| 135 | + | // Text startup pre cannot run without credentials; continue so | |
| 136 | + | // deferred InitialInput (pre-fill or auto-submit) still applies. | |
| 137 | + | i.mu.Lock() | |
| 138 | + | awaitingPre := i.awaitingStartupPre | |
| 139 | + | i.mu.Unlock() | |
| 140 | + | if awaitingPre { | |
| 141 | + | i.completeStartupPre() | |
| 142 | + | } | |
| 143 | + | return | |
| 144 | + | } | |
| 145 | + | // Pre-turn safety: if the most recent context measurement is | |
| 146 | + | // already past the auto-compact threshold, condense before | |
| 147 | + | // sending so the next outbound request stays under the limit. | |
| 148 | + | // The condense flow re-fires the user's queued prompt for us, so | |
| 149 | + | // we just hand it off and exit. | |
| 150 | + | i.mu.Lock() | |
| 151 | + | var resetHandoff json.RawMessage | |
| 152 | + | var persistResetHandoff bool | |
| 153 | + | if !continueExisting { | |
| 154 | + | resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked() | |
| 155 | + | } | |
| 156 | + | needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked() | |
| 157 | + | if needsPreCompact { | |
| 158 | + | // Reserve the compaction hand-off before releasing i.mu. A completion | |
| 159 | + | // update arriving in the small gap before runCompact acquires the | |
| 160 | + | // mutex must join the host queue rather than starting a competing turn. | |
| 161 | + | i.busy = true | |
| 162 | + | i.compacting = true | |
| 163 | + | i.autoCompacting = true | |
| 164 | + | if continueExisting { | |
| 165 | + | i.continueAfterCompact = true | |
| 166 | + | } else { | |
| 167 | + | i.pendingCompactPrompt = prompt | |
| 168 | + | i.pendingCompactImages = append([]provider.ImageBlock(nil), images...) | |
| 169 | + | i.hasPendingCompactPrompt = true | |
| 170 | + | } | |
| 171 | + | i.statusErr = "" | |
| 172 | + | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending...")) | |
| 173 | + | i.pendingPostCompactNote = "context auto-compacted; sending your last message" | |
| 174 | + | i.mu.Unlock() | |
| 175 | + | if persistResetHandoff { | |
| 176 | + | i.persistCompactHandoff(resetHandoff) | |
| 177 | + | } | |
| 178 | + | i.invalidate() | |
| 179 | + | i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold}) | |
| 180 | + | return | |
| 181 | + | } | |
| 182 | + | i.mu.Unlock() | |
| 183 | + | if persistResetHandoff { | |
| 184 | + | i.persistCompactHandoff(resetHandoff) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | ctx, cancel := context.WithCancel(parent) | |
| 188 | + | i.mu.Lock() | |
| 189 | + | i.busy = true | |
| 190 | + | i.spin.Start() | |
| 191 | + | i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model) | |
| 192 | + | i.cancelTurn = cancel | |
| 193 | + | i.statusErr = "" | |
| 194 | + | i.statusOK = "" | |
| 195 | + | i.streaming.Reset() | |
| 196 | + | i.streamOn = true | |
| 197 | + | i.pendingAlert = nil | |
| 198 | + | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 199 | + | i.toolOrder = nil | |
| 200 | + | i.toolGate = map[string]int{} | |
| 201 | + | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | |
| 202 | + | i.scrollOffset = 0 // jump back to the bottom on new turn | |
| 203 | + | // Lift the resume tail cap once the user starts interacting. The | |
| 204 | + | // cap is purely a first-paint optimization (don't markdown the | |
| 205 | + | // whole history before showing anything). Keeping it active during | |
| 206 | + | // a turn makes the rendered chat a sliding window: appended | |
| 207 | + | // messages push older ones off the TOP of the buffer, which the | |
| 208 | + | // renderer must treat as a change above the viewport and repaint | |
| 209 | + | // fully, snapping the terminal's native scrollback to the bottom on | |
| 210 | + | // every streamed chunk. A fresh session has no cap (append-only), | |
| 211 | + | // which is why the jump only shows up in resumed sessions. Dropping | |
| 212 | + | // the cap here makes resumed turns append-only too. | |
| 213 | + | i.view.TailLimit = 0 | |
| 214 | + | // Reset the auto-follow baseline so the very next render at | |
| 215 | + | // interactive.go:1053 doesn't see a synthetic shrink between | |
| 216 | + | // "last frame had the previous turn's tool overlay" and | |
| 217 | + | // "this frame had it cleared above". Without this, the guard | |
| 218 | + | // reads delta = -(rows in cleared overlay) and decrements | |
| 219 | + | // scrollOffset, which on terminals that mirror zut's pane | |
| 220 | + | // scroll into the host scrollbar visibly yanks the viewport. | |
| 221 | + | // See autofollow_shrink_test.go for the exact arithmetic. | |
| 222 | + | i.prevChatLen = 0 | |
| 223 | + | i.prevChatCols = 0 | |
| 224 | + | i.parkedTurn = 0 // starting a turn clears the /jump parked state | |
| 225 | + | i.parkedTotal = 0 | |
| 226 | + | i.helpBlock = nil // hide the help block once the user asks something | |
| 227 | + | i.mu.Unlock() | |
| 228 | + | i.invalidate() | |
| 229 | + | ||
| 230 | + | var ( | |
| 231 | + | lastStop provider.StopReason | |
| 232 | + | lastTurnErr error | |
| 233 | + | ) | |
| 234 | + | sink := func(ev core.AgentEvent) { | |
| 235 | + | if e, ok := ev.(core.EvTurnEnd); ok { | |
| 236 | + | lastStop = e.Stop | |
| 237 | + | lastTurnErr = e.Err | |
| 238 | + | } | |
| 239 | + | i.handleEventForPresentation(ev) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | releaseCompletionHold := i.beginCompletionDeliveryHold() | |
| 243 | + | go func() { | |
| 244 | + | defer releaseCompletionHold() | |
| 245 | + | var err error | |
| 246 | + | if continueExisting { | |
| 247 | + | err = i.agent.Continue(ctx, sink) | |
| 248 | + | } else { | |
| 249 | + | err = i.agent.Prompt(ctx, prompt, images, sink) | |
| 250 | + | } | |
| 251 | + | i.mu.Lock() | |
| 252 | + | // Keep busy asserted through final cleanup and queue selection. A | |
| 253 | + | // watcher may enqueue a completion summary concurrently with the | |
| 254 | + | // provider's final event; publishing idle before inspecting queues can | |
| 255 | + | // strand that summary in the core agent queue or start an overlapping | |
| 256 | + | // turn. | |
| 257 | + | // Don't touch streamPending / streamFlushPending here — the | |
| 258 | + | // pacer may still be draining the final deltas and needs to | |
| 259 | + | // paint them even though Prompt has returned. It will reset | |
| 260 | + | // streamOn on its own once the buffer empties. | |
| 261 | + | if len(i.streamPending) == 0 { | |
| 262 | + | i.streamOn = false | |
| 263 | + | } | |
| 264 | + | i.cancelTurn = nil | |
| 265 | + | pendingIdleWork := i.takePendingIdleWorkLocked() | |
| 266 | + | if err != nil && ctx.Err() == nil { | |
| 267 | + | i.statusErr = err.Error() | |
| 268 | + | } | |
| 269 | + | // Decide whether to offer a model rescue picker for recoverable | |
| 270 | + | // provider failures (auth/rate/temporary). The picker opens after | |
| 271 | + | // the mutex is released so it can take its own locks freely. | |
| 272 | + | var ( | |
| 273 | + | offer bool | |
| 274 | + | rescueWhy string | |
| 275 | + | rescueImgs []provider.ImageBlock | |
| 276 | + | rescueModel string | |
| 277 | + | rescueProv string | |
| 278 | + | rescueFprov string | |
| 279 | + | ) | |
| 280 | + | if err != nil && ctx.Err() == nil { | |
| 281 | + | if ok, reason := classifyRescueError(err); ok { | |
| 282 | + | offer = true | |
| 283 | + | rescueWhy = reason | |
| 284 | + | rescueImgs = images | |
| 285 | + | rescueModel = i.cfg.Model | |
| 286 | + | rescueProv = i.cfg.Provider | |
| 287 | + | rescueFprov = extractFailedProvider(err) | |
| 288 | + | if rescueFprov == "" { | |
| 289 | + | rescueFprov = i.cfg.Provider | |
| 290 | + | } | |
| 291 | + | // Suppress the red banner — the rescue dialog already | |
| 292 | + | // surfaces the failure. | |
| 293 | + | i.statusErr = "" | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | // Detect responses that reject the current context, either as an | |
| 297 | + | // HTTP 413 payload limit or a model context-window error. Token- | |
| 298 | + | // based auto-compact can miss both when metadata is stale or the | |
| 299 | + | // limit is measured in raw bytes. Compact once, then continue the | |
| 300 | + | // user message already present in the transcript. | |
| 301 | + | contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err) | |
| 302 | + | recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted | |
| 303 | + | if recoverContextOverflow { | |
| 304 | + | i.statusErr = "" | |
| 305 | + | i.continueAfterCompact = true | |
| 306 | + | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ...")) | |
| 307 | + | i.pendingPostCompactNote = "context auto-compacted; retrying your last message" | |
| 308 | + | } | |
| 309 | + | // Persist the assistant's reply (and every tool row before | |
| 310 | + | // it) to the session file while the turn memory is hot. | |
| 311 | + | // Without this, WriteNewTranscript only fires at zut exit, | |
| 312 | + | // meaning a crash or ungraceful kill drops the whole | |
| 313 | + | // conversation. FlushSession is idempotent (it advances the | |
| 314 | + | // baseline so subsequent flushes only write new rows). | |
| 315 | + | flush := i.cfg.FlushSession | |
| 316 | + | i.mu.Unlock() | |
| 317 | + | runPendingIdleWork(pendingIdleWork) | |
| 318 | + | if flush != nil { | |
| 319 | + | flush() | |
| 320 | + | } | |
| 321 | + | terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError) | |
| 322 | + | if terminalGoalError { | |
| 323 | + | i.updateActiveGoal(core.GoalBlocked, "turn ended with an error") | |
| 324 | + | } | |
| 325 | + | i.mu.Lock() | |
| 326 | + | awaitingPre := i.awaitingStartupPre | |
| 327 | + | // A newer explicit prompt may have cleared the handoff while the | |
| 328 | + | // completed turn was being flushed. Re-read it under the mutex before | |
| 329 | + | // deciding whether this continuation may spend another rescue attempt. | |
| 330 | + | statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue | |
| 331 | + | // Pop the next queued message, if any, and relaunch. | |
| 332 | + | var next string | |
| 333 | + | var hasNext bool | |
| 334 | + | if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil { | |
| 335 | + | next, i.queued = i.queued[0], i.queued[1:] | |
| 336 | + | hasNext = true | |
| 337 | + | } | |
| 338 | + | // If the turn was cancelled or errored, drop the queue so the | |
| 339 | + | // user isn't bombarded with stale messages after an interrupt. | |
| 340 | + | if ctx.Err() != nil || (err != nil && !recoverContextOverflow) { | |
| 341 | + | i.queued = nil | |
| 342 | + | if i.agent != nil { | |
| 343 | + | i.agent.DrainQueuedMessages() | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | // Decide whether the next thing to do is an auto-compaction. | |
| 347 | + | // Only fires when the turn completed cleanly AND no host-side | |
| 348 | + | // or agent-side queued messages are waiting (otherwise a queued | |
| 349 | + | // message would race the condense). | |
| 350 | + | agentQueued := 0 | |
| 351 | + | if i.agent != nil { | |
| 352 | + | agentQueued = i.agent.QueuedMessageCount() | |
| 353 | + | } | |
| 354 | + | continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil | |
| 355 | + | shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked() | |
| 356 | + | continueStatusRescue := false | |
| 357 | + | var handoff json.RawMessage | |
| 358 | + | var persistHandoff bool | |
| 359 | + | if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil { | |
| 360 | + | followUpMessages := i.agent.Messages() | |
| 361 | + | reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages) | |
| 362 | + | if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations { | |
| 363 | + | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{ | |
| 364 | + | reason: compactContinuationStatusRescue, | |
| 365 | + | rescueAttempts: i.compactContinuation.rescueAttempts + 1, | |
| 366 | + | }) | |
| 367 | + | continueStatusRescue = true | |
| 368 | + | } else { | |
| 369 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) { | |
| 373 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 374 | + | } | |
| 375 | + | // The agent run can finish before the paced final text reaches the | |
| 376 | + | // transcript. A compaction replaces that transcript, so it must never | |
| 377 | + | // race the still-live stream frame; otherwise stale deltas can repaint | |
| 378 | + | // after the replacement and corrupt the scrollback renderer's model. | |
| 379 | + | if recoverContextOverflow || shouldAutoCompact { | |
| 380 | + | i.resetStreamingStateLocked() | |
| 381 | + | } | |
| 382 | + | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact) | |
| 383 | + | i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact | |
| 384 | + | i.mu.Unlock() | |
| 385 | + | if persistHandoff { | |
| 386 | + | i.persistCompactHandoff(handoff) | |
| 387 | + | } | |
| 388 | + | if alertReason != "" { | |
| 389 | + | i.scheduleMainAlert(alertReason) | |
| 390 | + | } | |
| 391 | + | i.invalidate() | |
| 392 | + | parent := i.runCtx | |
| 393 | + | if parent == nil { | |
| 394 | + | parent = context.Background() | |
| 395 | + | } | |
| 396 | + | if awaitingPre { | |
| 397 | + | i.completeStartupPre() | |
| 398 | + | return | |
| 399 | + | } | |
| 400 | + | switch { | |
| 401 | + | case hasNext: | |
| 402 | + | i.startTurn(parent, next) | |
| 403 | + | case continueQueued: | |
| 404 | + | i.startTurnRequest(parent, "", nil, true, false) | |
| 405 | + | case continueStatusRescue: | |
| 406 | + | i.startAutoCompactContinuation(parent) | |
| 407 | + | case offer: | |
| 408 | + | i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs) | |
| 409 | + | case recoverContextOverflow: | |
| 410 | + | i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery}) | |
| 411 | + | case shouldAutoCompact: | |
| 412 | + | i.runCompact(parent, compactContinuationRequest{ | |
| 413 | + | origin: compactOriginAfterTurnThreshold, | |
| 414 | + | force: lastStop == provider.StopLength, | |
| 415 | + | lastStop: lastStop, | |
| 416 | + | turnError: lastTurnErr, | |
| 417 | + | }) | |
| 418 | + | } | |
| 419 | + | }() | |
| 420 | + | } | |
| 421 | + | func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string { | |
| 422 | + | if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting { | |
| 423 | + | return "" | |
| 424 | + | } | |
| 425 | + | if rescue { | |
| 426 | + | return "rescue_required" | |
| 427 | + | } | |
| 428 | + | if stop == provider.StopAborted { | |
| 429 | + | return "" | |
| 430 | + | } | |
| 431 | + | if err != nil || turnErr != nil || stop == provider.StopError { | |
| 432 | + | return "agent_error" | |
| 433 | + | } | |
| 434 | + | if stop == provider.StopLength { | |
| 435 | + | return "response_truncated" | |
| 436 | + | } | |
| 437 | + | return "agent_done" | |
| 438 | + | } | |
| 439 | + | func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) { | |
| 440 | + | if i.rescueDialog == nil { | |
| 441 | + | return | |
| 442 | + | } | |
| 443 | + | loggedIn := []string{} | |
| 444 | + | if i.cfg.LoggedInProviders != nil { | |
| 445 | + | loggedIn = i.cfg.LoggedInProviders() | |
| 446 | + | } | |
| 447 | + | fprov := failedProvider | |
| 448 | + | if fprov == "" { | |
| 449 | + | fprov = activeProvider | |
| 450 | + | } | |
| 451 | + | i.mu.Lock() | |
| 452 | + | i.pendingRescuePrompt = prompt | |
| 453 | + | i.pendingRescueImages = images | |
| 454 | + | i.mu.Unlock() | |
| 455 | + | i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt) | |
| 456 | + | i.invalidate() | |
| 457 | + | } | |
| 458 | + | func (i *Interactive) applyRescueSelection(prov, model, prompt string) { | |
| 459 | + | if model == "" { | |
| 460 | + | return | |
| 461 | + | } | |
| 462 | + | i.applyRescueModelSelection(prov, model) | |
| 463 | + | i.mu.Lock() | |
| 464 | + | images := i.pendingRescueImages | |
| 465 | + | if prompt == "" { | |
| 466 | + | prompt = i.pendingRescuePrompt | |
| 467 | + | } | |
| 468 | + | i.pendingRescuePrompt = "" | |
| 469 | + | i.pendingRescueImages = nil | |
| 470 | + | i.mu.Unlock() | |
| 471 | + | parent := i.runCtx | |
| 472 | + | if parent == nil { | |
| 473 | + | parent = context.Background() | |
| 474 | + | } | |
| 475 | + | i.startTurnWithImages(parent, prompt, images) | |
| 476 | + | } | |
| 477 | + | func stripAutoCompactNotes(notes []string) []string { | |
| 478 | + | if len(notes) == 0 { | |
| 479 | + | return notes | |
| 480 | + | } | |
| 481 | + | out := notes[:0] | |
| 482 | + | for _, n := range notes { | |
| 483 | + | if strings.Contains(n, "condensing history") { | |
| 484 | + | continue | |
| 485 | + | } | |
| 486 | + | out = append(out, n) | |
| 487 | + | } | |
| 488 | + | return out | |
| 489 | + | } | |
| 490 | + | func autoCompactNoteLine(th tui.Theme, msg string) string { | |
| 491 | + | return " " + th.FGColor(th.Warning, "⚠ "+msg) | |
| 492 | + | } | |
| 493 | + | func NormalizeAutoCompactThreshold(threshold *int) int { | |
| 494 | + | const defaultAutoCompactThreshold = 85 | |
| 495 | + | if threshold == nil { | |
| 496 | + | return defaultAutoCompactThreshold | |
| 497 | + | } | |
| 498 | + | switch *threshold { | |
| 499 | + | case 0, 70, 80, 85, 90, 95: | |
| 500 | + | return *threshold | |
| 501 | + | default: | |
| 502 | + | return defaultAutoCompactThreshold | |
| 503 | + | } | |
| 504 | + | } | |
| 505 | + | func ShouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool { | |
| 506 | + | if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 { | |
| 507 | + | return false | |
| 508 | + | } | |
| 509 | + | return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100 | |
| 510 | + | } | |
| 511 | + | func classifyCompactionContinuation(origin compactContinuationOrigin, statusRescueActive bool, stop provider.StopReason, turnErr error, msgs []provider.Message) compactContinuationReason { | |
| 512 | + | if origin != compactOriginManual && structuralCompactionContinuation(msgs) { | |
| 513 | + | return compactContinuationStructuralTail | |
| 514 | + | } | |
| 515 | + | if (origin != compactOriginAfterTurnThreshold && !statusRescueActive) || stop != provider.StopEnd || turnErr != nil { | |
| 516 | + | return compactContinuationNone | |
| 517 | + | } | |
| 518 | + | if likelyForwardWorkStatus(msgs) { | |
| 519 | + | return compactContinuationStatusRescue | |
| 520 | + | } | |
| 521 | + | return compactContinuationNone | |
| 522 | + | } | |
| 523 | + | func structuralCompactionContinuation(msgs []provider.Message) bool { | |
| 524 | + | if len(msgs) == 0 { | |
| 525 | + | return false | |
| 526 | + | } | |
| 527 | + | last := msgs[len(msgs)-1] | |
| 528 | + | if last.Role != provider.RoleAssistant { | |
| 529 | + | return true | |
| 530 | + | } | |
| 531 | + | hasText := false | |
| 532 | + | for _, content := range last.Content { | |
| 533 | + | switch block := content.(type) { | |
| 534 | + | case provider.ToolCallBlock: | |
| 535 | + | return true | |
| 536 | + | case provider.TextBlock: | |
| 537 | + | if strings.TrimSpace(block.Text) != "" { | |
| 538 | + | hasText = true | |
| 539 | + | } | |
| 540 | + | case provider.ReasoningBlock: | |
| 541 | + | // Reasoning without a visible answer is not terminal. | |
| 542 | + | default: | |
| 543 | + | return true | |
| 544 | + | } | |
| 545 | + | } | |
| 546 | + | return !hasText | |
| 547 | + | } | |
| 548 | + | func likelyForwardWorkStatus(msgs []provider.Message) bool { | |
| 549 | + | if len(msgs) == 0 { | |
| 550 | + | return false | |
| 551 | + | } | |
| 552 | + | last := msgs[len(msgs)-1] | |
| 553 | + | if last.Role != provider.RoleAssistant { | |
| 554 | + | return false | |
| 555 | + | } | |
| 556 | + | var text strings.Builder | |
| 557 | + | for _, content := range last.Content { | |
| 558 | + | block, ok := content.(provider.TextBlock) | |
| 559 | + | if !ok { | |
| 560 | + | if _, hasTool := content.(provider.ToolCallBlock); hasTool { | |
| 561 | + | return false | |
| 562 | + | } | |
| 563 | + | continue | |
| 564 | + | } | |
| 565 | + | if strings.TrimSpace(block.Text) == "" { | |
| 566 | + | continue | |
| 567 | + | } | |
| 568 | + | if text.Len() > 0 { | |
| 569 | + | text.WriteByte(' ') | |
| 570 | + | } | |
| 571 | + | text.WriteString(block.Text) | |
| 572 | + | } | |
| 573 | + | if text.Len() == 0 { | |
| 574 | + | return false | |
| 575 | + | } | |
| 576 | + | normalized := strings.ToLower(strings.Join(strings.Fields(text.String()), " ")) | |
| 577 | + | normalized = strings.NewReplacer("’", "'", "‘", "'", "`", "'").Replace(normalized) | |
| 578 | + | for _, phrase := range []string{ | |
| 579 | + | "next i will", | |
| 580 | + | "next, i will", | |
| 581 | + | "i'll now", | |
| 582 | + | "i will now", | |
| 583 | + | "i am going to", | |
| 584 | + | "i need to continue", | |
| 585 | + | "then i will", | |
| 586 | + | "then, i will", | |
| 587 | + | "i will proceed", | |
| 588 | + | } { | |
| 589 | + | if containsFutureWorkPhrase(normalized, phrase) { | |
| 590 | + | return true | |
| 591 | + | } | |
| 592 | + | } | |
| 593 | + | return false | |
| 594 | + | } | |
| 595 | + | func containsFutureWorkPhrase(text, phrase string) bool { | |
| 596 | + | for offset := 0; offset < len(text); { | |
| 597 | + | index := strings.Index(text[offset:], phrase) | |
| 598 | + | if index < 0 { | |
| 599 | + | return false | |
| 600 | + | } | |
| 601 | + | index += offset | |
| 602 | + | end := index + len(phrase) | |
| 603 | + | if !wordRuneBefore(text, index) && !wordRuneAfter(text, end) { | |
| 604 | + | return true | |
| 605 | + | } | |
| 606 | + | offset = end | |
| 607 | + | } | |
| 608 | + | return false | |
| 609 | + | } | |
| 610 | + | func wordRuneBefore(text string, index int) bool { | |
| 611 | + | if index == 0 { | |
| 612 | + | return false | |
| 613 | + | } | |
| 614 | + | rune_, _ := utf8.DecodeLastRuneInString(text[:index]) | |
| 615 | + | return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_' | |
| 616 | + | } | |
| 617 | + | func wordRuneAfter(text string, index int) bool { | |
| 618 | + | if index == len(text) { | |
| 619 | + | return false | |
| 620 | + | } | |
| 621 | + | rune_, _ := utf8.DecodeRuneInString(text[index:]) | |
| 622 | + | return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_' | |
| 623 | + | } | |
| 624 | + | func (i *Interactive) shouldAutoCompactLocked() bool { | |
| 625 | + | if i.agent == nil { | |
| 626 | + | return false | |
| 627 | + | } | |
| 628 | + | if i.autoCompacting { | |
| 629 | + | return false | |
| 630 | + | } | |
| 631 | + | m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model) | |
| 632 | + | if err != nil || m.ContextWindow <= 0 { | |
| 633 | + | return false | |
| 634 | + | } | |
| 635 | + | threshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold) | |
| 636 | + | return ShouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold) | |
| 637 | + | } | |