Skip to content

Commit 2678580

Browse files
authored
Merge pull request #613 from riturajFi/feat/command_history_in_tui
implemented command history
2 parents d838966 + 60c0a59 commit 2678580

2 files changed

Lines changed: 154 additions & 5 deletions

File tree

pkg/tui/components/editor/editor.go

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/charmbracelet/bubbles/v2/textarea"
77
tea "github.com/charmbracelet/bubbletea/v2"
88

9+
"github.com/docker/cagent/pkg/history"
910
"github.com/docker/cagent/pkg/tui/core"
1011
"github.com/docker/cagent/pkg/tui/core/layout"
1112
"github.com/docker/cagent/pkg/tui/styles"
@@ -16,12 +17,21 @@ type SendMsg struct {
1617
Content string
1718
}
1819

20+
// historyNavigation describes which direction we want to pull from history.
21+
type historyNavigation int
22+
23+
const (
24+
NAVIGATEPREVIOUS historyNavigation = iota
25+
NAVIGATENEXT
26+
)
27+
1928
// Editor represents an input editor component
2029
type Editor interface {
2130
layout.Model
2231
layout.Sizeable
2332
layout.Focusable
2433
layout.Help
34+
SetHistory(hist *history.History)
2535
SetWorking(working bool) tea.Cmd
2636
}
2737

@@ -31,6 +41,13 @@ type editor struct {
3141
width int
3242
height int
3343
working bool
44+
45+
// history is the shared command store backing up/down navigation.
46+
hist *history.History
47+
// draftInput holds the user's unsent text while they browse history.
48+
draftInput string
49+
// historyBrowsing marks that we're currently showing history entries.
50+
historyBrowsing bool
3451
}
3552

3653
// New creates a new editor component
@@ -70,14 +87,29 @@ func (e *editor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7087
}
7188
value := e.textarea.Value()
7289
if value != "" && !e.working {
90+
// Treat enter as send: clear input and exit history browse state.
7391
e.textarea.Reset()
74-
return e, core.CmdHandler(SendMsg{
75-
Content: value,
76-
})
92+
e.endHistoryBrowse()
93+
return e, core.CmdHandler(SendMsg{Content: value})
7794
}
7895
return e, nil
7996
case "ctrl+c":
8097
return e, tea.Quit
98+
case "up":
99+
// Consume the key when we replace the buffer with an older command.
100+
if e.navigateHistory(NAVIGATEPREVIOUS) {
101+
return e, nil
102+
}
103+
case "down":
104+
// Consume the key when we replace the buffer with a newer command.
105+
if e.navigateHistory(NAVIGATENEXT) {
106+
return e, nil
107+
}
108+
default:
109+
// Any other key exits history browsing so input becomes fresh text.
110+
if e.historyBrowsing {
111+
e.endHistoryBrowse()
112+
}
81113
}
82114
}
83115

@@ -141,3 +173,90 @@ func (e *editor) SetWorking(working bool) tea.Cmd {
141173
e.working = working
142174
return nil
143175
}
176+
177+
func (e *editor) SetHistory(hist *history.History) {
178+
e.hist = hist
179+
}
180+
181+
func (e *editor) navigateHistory(direction historyNavigation) bool {
182+
// Returning true tells Update to stop Bubble Tea's default cursor handling,
183+
// because we've already replaced the textarea content for this key press.
184+
if !e.canBrowseHistory() {
185+
return false
186+
}
187+
188+
if !e.historyBrowsing {
189+
e.beginHistoryBrowse()
190+
}
191+
192+
var entry string
193+
switch direction {
194+
case NAVIGATEPREVIOUS:
195+
// Up arrow walks toward older commands.
196+
entry = e.hist.Previous()
197+
case NAVIGATENEXT:
198+
// Down arrow walks toward newer commands.
199+
entry = e.hist.Next()
200+
if entry == "" {
201+
// Restore the draft when we step past the newest entry.
202+
e.restoreDraftFromHistory()
203+
return true
204+
}
205+
default:
206+
return false
207+
}
208+
209+
if entry == "" {
210+
return true
211+
}
212+
213+
// Replace the input with the selected history entry.
214+
e.textarea.SetValue(entry)
215+
// Place the cursor at the end so the user can immediately append or send.
216+
e.textarea.MoveToEnd()
217+
return true
218+
}
219+
220+
func (e *editor) canBrowseHistory() bool {
221+
// We only take over arrow keys when there's at least one history entry and
222+
// the textarea is a single line (multi-line inputs retain normal movement).
223+
return e.hist != nil &&
224+
len(e.hist.Messages) > 0 &&
225+
e.textarea.LineCount() == 1
226+
}
227+
228+
func (e *editor) beginHistoryBrowse() {
229+
if e.hist == nil {
230+
return
231+
}
232+
// Capture the in-progress text so we can restore it after browsing.
233+
e.draftInput = e.textarea.Value()
234+
e.historyBrowsing = true
235+
// Start from the newest entry so the first "up" pulls the latest command.
236+
e.moveHistoryCursorToLatest()
237+
}
238+
239+
func (e *editor) restoreDraftFromHistory() {
240+
e.textarea.SetValue(e.draftInput)
241+
e.textarea.MoveToEnd()
242+
e.endHistoryBrowse()
243+
}
244+
245+
func (e *editor) endHistoryBrowse() {
246+
e.historyBrowsing = false
247+
e.draftInput = ""
248+
if e.hist == nil {
249+
return
250+
}
251+
e.moveHistoryCursorToLatest()
252+
}
253+
254+
func (e *editor) moveHistoryCursorToLatest() {
255+
if e.hist == nil {
256+
return
257+
}
258+
// Advance until Next returns empty, which positions the cursor just after
259+
// the most recent saved command.
260+
for e.hist.Next() != "" {
261+
}
262+
}

pkg/tui/page/chat/chat.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/charmbracelet/lipgloss/v2"
1414

1515
"github.com/docker/cagent/pkg/app"
16+
"github.com/docker/cagent/pkg/history"
1617
"github.com/docker/cagent/pkg/runtime"
1718
"github.com/docker/cagent/pkg/tui/components/editor"
1819
"github.com/docker/cagent/pkg/tui/components/messages"
@@ -65,6 +66,10 @@ type chatPage struct {
6566
title string
6667
app *app.App
6768

69+
history *history.History
70+
// Track the most recently stored command to prevent duplicate entries.
71+
lastHistoryEntry string
72+
6873
// Cached layout dimensions
6974
chatHeight int
7075
inputHeight int
@@ -92,15 +97,32 @@ func defaultKeyMap() KeyMap {
9297

9398
// New creates a new chat page
9499
func New(a *app.App) Page {
95-
return &chatPage{
100+
// Load persisted command history shared with the editor.
101+
historyStore, err := history.New()
102+
if err != nil {
103+
fmt.Fprintf(os.Stderr, "failed to initialize command history: %v\n", err)
104+
}
105+
106+
ed := editor.New()
107+
// Give the editor access to the shared history for navigation.
108+
ed.SetHistory(historyStore)
109+
110+
page := &chatPage{
96111
title: a.Title(),
97112
sidebar: sidebar.New(),
98113
messages: messages.New(a),
99-
editor: editor.New(),
114+
editor: ed,
100115
focusedPanel: PanelEditor,
101116
app: a,
102117
keyMap: defaultKeyMap(),
118+
history: historyStore,
103119
}
120+
121+
if historyStore != nil && len(historyStore.Messages) > 0 {
122+
page.lastHistoryEntry = historyStore.Messages[len(historyStore.Messages)-1]
123+
}
124+
125+
return page
104126
}
105127

106128
// Init initializes the chat page
@@ -180,6 +202,14 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
180202
return p, cmd
181203

182204
case editor.SendMsg:
205+
// Persist every submitted command before handing it to the runtime.
206+
if p.history != nil && msg.Content != p.lastHistoryEntry {
207+
if err := p.history.Add(msg.Content); err != nil {
208+
fmt.Fprintf(os.Stderr, "failed to persist command history: %v\n", err)
209+
} else {
210+
p.lastHistoryEntry = msg.Content
211+
}
212+
}
183213
cmd := p.processMessage(msg.Content)
184214
return p, cmd
185215

0 commit comments

Comments
 (0)