interactive: browse prompt history with arrows
- commit
- 266b1a52a4194f3fe5ec79f9442f3a61f71ab60b
- author
- patriceckhart · · 2026-05-03 09:26 UTC
- parent
- 527daf7
- signature
- unsigned
- refs
- tag: v0.1.44
1 file · +89 -0
M internal/agent/modes/interactive.go +89 -0
| @@ -253,6 +253,12 @@ type Interactive struct { | |||
| 253 | 253 | parkedTurn int | |
| 254 | 254 | parkedTotal int | |
| 255 | 255 | ||
| 256 | + | // inputHistoryIndex is -1 when not browsing history. When the | |
| 257 | + | // editor is empty, Left/Right can walk previous user prompts for | |
| 258 | + | // quick manual testing without stealing normal cursor movement in | |
| 259 | + | // non-empty input. | |
| 260 | + | inputHistoryIndex int | |
| 261 | + | ||
| 256 | 262 | // lastCtrlC is when the user last pressed ctrl+c. The first press | |
| 257 | 263 | // clears the editor / cancels a turn / shows a hint; a second press | |
| 258 | 264 | // within ctrlCExitWindow exits. Mirrors the python-repl convention. | |
| @@ -305,6 +311,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 305 | 311 | suggest: newSlashSuggester(), | |
| 306 | 312 | fileSuggest: newFileSuggester(), | |
| 307 | 313 | spin: newSpinner(), | |
| 314 | + | inputHistoryIndex: -1, | |
| 308 | 315 | } | |
| 309 | 316 | if cfg.Agent != nil { | |
| 310 | 317 | i.agent = cfg.Agent | |
| @@ -1635,6 +1642,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1635 | 1642 | } | |
| 1636 | 1643 | } | |
| 1637 | 1644 | ||
| 1645 | + | if i.handleInputHistoryKey(k) { | |
| 1646 | + | return false | |
| 1647 | + | } | |
| 1648 | + | if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight { | |
| 1649 | + | i.inputHistoryIndex = -1 | |
| 1650 | + | } | |
| 1651 | + | ||
| 1638 | 1652 | if submit := i.ed.HandleKey(k); submit { | |
| 1639 | 1653 | // SubmitValue() expands any [pasted text #N +L lines] | |
| 1640 | 1654 | // placeholders back into their bodies; the raw Value() | |
| @@ -1646,6 +1660,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1646 | 1660 | return false | |
| 1647 | 1661 | } | |
| 1648 | 1662 | i.ed.Clear() | |
| 1663 | + | i.inputHistoryIndex = -1 | |
| 1649 | 1664 | i.suggest.Reset() | |
| 1650 | 1665 | i.fileSuggest.Reset() | |
| 1651 | 1666 | ||
| @@ -1713,10 +1728,84 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1713 | 1728 | return false | |
| 1714 | 1729 | } | |
| 1715 | 1730 | ||
| 1731 | + | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | |
| 1732 | + | if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight { | |
| 1733 | + | return false | |
| 1734 | + | } | |
| 1735 | + | // Do not steal normal cursor movement. History browsing can only | |
| 1736 | + | // start from an empty editor; once active, Left/Right keep walking | |
| 1737 | + | // the ring so repeated presses work even though the editor now | |
| 1738 | + | // contains the selected historical prompt. | |
| 1739 | + | if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() { | |
| 1740 | + | return false | |
| 1741 | + | } | |
| 1742 | + | hist := i.inputHistory() | |
| 1743 | + | if len(hist) == 0 { | |
| 1744 | + | return false | |
| 1745 | + | } | |
| 1746 | + | ||
| 1747 | + | if i.inputHistoryIndex < 0 { | |
| 1748 | + | // Start just after the newest item so Left lands on the most | |
| 1749 | + | // recent user prompt and Right keeps the editor empty. | |
| 1750 | + | i.inputHistoryIndex = len(hist) | |
| 1751 | + | } | |
| 1752 | + | ||
| 1753 | + | switch k.Kind { | |
| 1754 | + | case tui.KeyLeft: | |
| 1755 | + | if i.inputHistoryIndex > 0 { | |
| 1756 | + | i.inputHistoryIndex-- | |
| 1757 | + | } | |
| 1758 | + | case tui.KeyRight: | |
| 1759 | + | if i.inputHistoryIndex < len(hist) { | |
| 1760 | + | i.inputHistoryIndex++ | |
| 1761 | + | } | |
| 1762 | + | } | |
| 1763 | + | ||
| 1764 | + | if i.inputHistoryIndex >= len(hist) { | |
| 1765 | + | i.ed.Clear() | |
| 1766 | + | } else { | |
| 1767 | + | i.ed.SetValue(hist[i.inputHistoryIndex]) | |
| 1768 | + | } | |
| 1769 | + | return true | |
| 1770 | + | } | |
| 1771 | + | ||
| 1772 | + | func (i *Interactive) inputHistory() []string { | |
| 1773 | + | if i.agent == nil { | |
| 1774 | + | return nil | |
| 1775 | + | } | |
| 1776 | + | msgs := i.agent.Messages() | |
| 1777 | + | hist := make([]string, 0, len(msgs)) | |
| 1778 | + | for _, m := range msgs { | |
| 1779 | + | if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) { | |
| 1780 | + | continue | |
| 1781 | + | } | |
| 1782 | + | text := userMessageText(m) | |
| 1783 | + | if strings.TrimSpace(text) == "" { | |
| 1784 | + | continue | |
| 1785 | + | } | |
| 1786 | + | hist = append(hist, text) | |
| 1787 | + | } | |
| 1788 | + | return hist | |
| 1789 | + | } | |
| 1790 | + | ||
| 1791 | + | func userMessageText(m provider.Message) string { | |
| 1792 | + | var sb strings.Builder | |
| 1793 | + | for _, c := range m.Content { | |
| 1794 | + | if tb, ok := c.(provider.TextBlock); ok { | |
| 1795 | + | if sb.Len() > 0 { | |
| 1796 | + | sb.WriteByte('\n') | |
| 1797 | + | } | |
| 1798 | + | sb.WriteString(tb.Text) | |
| 1799 | + | } | |
| 1800 | + | } | |
| 1801 | + | return sb.String() | |
| 1802 | + | } | |
| 1803 | + | ||
| 1716 | 1804 | // invokeExtensionCommand fires an extension-registered slash command | |
| 1717 | 1805 | // in a background goroutine, awaits the response, and applies the | |
| 1718 | 1806 | // requested action (prompt / insert / display / noop). Errors and | |
| 1719 | 1807 | // timeouts surface as a status_err line. | |
| 1808 | + | ||
| 1720 | 1809 | func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) { | |
| 1721 | 1810 | resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second) | |
| 1722 | 1811 | if err != nil { | |
| @@ -253,6 +253,12 @@ type Interactive struct { | |||||
| 253 | parkedTurn int | 253 | parkedTurn int | ||
| 254 | parkedTotal int | 254 | parkedTotal int | ||
| 255 | 255 | ||||
| 256 | + | // inputHistoryIndex is -1 when not browsing history. When the | |||
| 257 | + | // editor is empty, Left/Right can walk previous user prompts for | |||
| 258 | + | // quick manual testing without stealing normal cursor movement in | |||
| 259 | + | // non-empty input. | |||
| 260 | + | inputHistoryIndex int | |||
| 261 | + | ||||
| 256 | // lastCtrlC is when the user last pressed ctrl+c. The first press | 262 | // lastCtrlC is when the user last pressed ctrl+c. The first press | ||
| 257 | // clears the editor / cancels a turn / shows a hint; a second press | 263 | // clears the editor / cancels a turn / shows a hint; a second press | ||
| 258 | // within ctrlCExitWindow exits. Mirrors the python-repl convention. | 264 | // within ctrlCExitWindow exits. Mirrors the python-repl convention. | ||
| @@ -305,6 +311,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 305 | suggest: newSlashSuggester(), | 311 | suggest: newSlashSuggester(), | ||
| 306 | fileSuggest: newFileSuggester(), | 312 | fileSuggest: newFileSuggester(), | ||
| 307 | spin: newSpinner(), | 313 | spin: newSpinner(), | ||
| 314 | + | inputHistoryIndex: -1, | |||
| 308 | } | 315 | } | ||
| 309 | if cfg.Agent != nil { | 316 | if cfg.Agent != nil { | ||
| 310 | i.agent = cfg.Agent | 317 | i.agent = cfg.Agent | ||
| @@ -1635,6 +1642,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1635 | } | 1642 | } | ||
| 1636 | } | 1643 | } | ||
| 1637 | 1644 | ||||
| 1645 | + | if i.handleInputHistoryKey(k) { | |||
| 1646 | + | return false | |||
| 1647 | + | } | |||
| 1648 | + | if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight { | |||
| 1649 | + | i.inputHistoryIndex = -1 | |||
| 1650 | + | } | |||
| 1651 | + | ||||
| 1638 | if submit := i.ed.HandleKey(k); submit { | 1652 | if submit := i.ed.HandleKey(k); submit { | ||
| 1639 | // SubmitValue() expands any [pasted text #N +L lines] | 1653 | // SubmitValue() expands any [pasted text #N +L lines] | ||
| 1640 | // placeholders back into their bodies; the raw Value() | 1654 | // placeholders back into their bodies; the raw Value() | ||
| @@ -1646,6 +1660,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1646 | return false | 1660 | return false | ||
| 1647 | } | 1661 | } | ||
| 1648 | i.ed.Clear() | 1662 | i.ed.Clear() | ||
| 1663 | + | i.inputHistoryIndex = -1 | |||
| 1649 | i.suggest.Reset() | 1664 | i.suggest.Reset() | ||
| 1650 | i.fileSuggest.Reset() | 1665 | i.fileSuggest.Reset() | ||
| 1651 | 1666 | ||||
| @@ -1713,10 +1728,84 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1713 | return false | 1728 | return false | ||
| 1714 | } | 1729 | } | ||
| 1715 | 1730 | ||||
| 1731 | + | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | |||
| 1732 | + | if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight { | |||
| 1733 | + | return false | |||
| 1734 | + | } | |||
| 1735 | + | // Do not steal normal cursor movement. History browsing can only | |||
| 1736 | + | // start from an empty editor; once active, Left/Right keep walking | |||
| 1737 | + | // the ring so repeated presses work even though the editor now | |||
| 1738 | + | // contains the selected historical prompt. | |||
| 1739 | + | if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() { | |||
| 1740 | + | return false | |||
| 1741 | + | } | |||
| 1742 | + | hist := i.inputHistory() | |||
| 1743 | + | if len(hist) == 0 { | |||
| 1744 | + | return false | |||
| 1745 | + | } | |||
| 1746 | + | ||||
| 1747 | + | if i.inputHistoryIndex < 0 { | |||
| 1748 | + | // Start just after the newest item so Left lands on the most | |||
| 1749 | + | // recent user prompt and Right keeps the editor empty. | |||
| 1750 | + | i.inputHistoryIndex = len(hist) | |||
| 1751 | + | } | |||
| 1752 | + | ||||
| 1753 | + | switch k.Kind { | |||
| 1754 | + | case tui.KeyLeft: | |||
| 1755 | + | if i.inputHistoryIndex > 0 { | |||
| 1756 | + | i.inputHistoryIndex-- | |||
| 1757 | + | } | |||
| 1758 | + | case tui.KeyRight: | |||
| 1759 | + | if i.inputHistoryIndex < len(hist) { | |||
| 1760 | + | i.inputHistoryIndex++ | |||
| 1761 | + | } | |||
| 1762 | + | } | |||
| 1763 | + | ||||
| 1764 | + | if i.inputHistoryIndex >= len(hist) { | |||
| 1765 | + | i.ed.Clear() | |||
| 1766 | + | } else { | |||
| 1767 | + | i.ed.SetValue(hist[i.inputHistoryIndex]) | |||
| 1768 | + | } | |||
| 1769 | + | return true | |||
| 1770 | + | } | |||
| 1771 | + | ||||
| 1772 | + | func (i *Interactive) inputHistory() []string { | |||
| 1773 | + | if i.agent == nil { | |||
| 1774 | + | return nil | |||
| 1775 | + | } | |||
| 1776 | + | msgs := i.agent.Messages() | |||
| 1777 | + | hist := make([]string, 0, len(msgs)) | |||
| 1778 | + | for _, m := range msgs { | |||
| 1779 | + | if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) { | |||
| 1780 | + | continue | |||
| 1781 | + | } | |||
| 1782 | + | text := userMessageText(m) | |||
| 1783 | + | if strings.TrimSpace(text) == "" { | |||
| 1784 | + | continue | |||
| 1785 | + | } | |||
| 1786 | + | hist = append(hist, text) | |||
| 1787 | + | } | |||
| 1788 | + | return hist | |||
| 1789 | + | } | |||
| 1790 | + | ||||
| 1791 | + | func userMessageText(m provider.Message) string { | |||
| 1792 | + | var sb strings.Builder | |||
| 1793 | + | for _, c := range m.Content { | |||
| 1794 | + | if tb, ok := c.(provider.TextBlock); ok { | |||
| 1795 | + | if sb.Len() > 0 { | |||
| 1796 | + | sb.WriteByte('\n') | |||
| 1797 | + | } | |||
| 1798 | + | sb.WriteString(tb.Text) | |||
| 1799 | + | } | |||
| 1800 | + | } | |||
| 1801 | + | return sb.String() | |||
| 1802 | + | } | |||
| 1803 | + | ||||
| 1716 | // invokeExtensionCommand fires an extension-registered slash command | 1804 | // invokeExtensionCommand fires an extension-registered slash command | ||
| 1717 | // in a background goroutine, awaits the response, and applies the | 1805 | // in a background goroutine, awaits the response, and applies the | ||
| 1718 | // requested action (prompt / insert / display / noop). Errors and | 1806 | // requested action (prompt / insert / display / noop). Errors and | ||
| 1719 | // timeouts surface as a status_err line. | 1807 | // timeouts surface as a status_err line. | ||
| 1808 | + | ||||
| 1720 | func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) { | 1809 | func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) { | ||
| 1721 | resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second) | 1810 | resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second) | ||
| 1722 | if err != nil { | 1811 | if err != nil { | ||