Skip to content

Commit f88f0aa

Browse files
committed
feat: support history input
1 parent 680038f commit f88f0aa

4 files changed

Lines changed: 211 additions & 0 deletions

File tree

internal/storage/history.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package storage
2+
3+
import (
4+
"bufio"
5+
"encoding/json"
6+
"os"
7+
"path/filepath"
8+
"slices"
9+
"sync"
10+
"time"
11+
)
12+
13+
const maxHistoryItems = 500
14+
15+
// HistoryEntry is one line in the JSONL history file.
16+
type HistoryEntry struct {
17+
Display string `json:"display"`
18+
PastedContents map[string]any `json:"pastedContents"`
19+
Timestamp int64 `json:"timestamp"`
20+
Project string `json:"project"`
21+
SessionID string `json:"sessionId,omitempty"`
22+
}
23+
24+
// History provides append-only input history with per-project filtering.
25+
// The backing file (~/.codebot/history.jsonl) is shared across all projects;
26+
// only entries matching the current project are surfaced.
27+
type History struct {
28+
path string
29+
project string
30+
sessionID string
31+
items []string // current-project entries, newest first (index 0 = most recent)
32+
mu sync.Mutex
33+
}
34+
35+
// NewHistory loads history from path, filtering by project.
36+
func NewHistory(path, project, sessionID string) *History {
37+
h := &History{path: path, project: project, sessionID: sessionID}
38+
h.load()
39+
return h
40+
}
41+
42+
// SetSessionID updates the session ID for subsequent entries (e.g. after session switch).
43+
func (h *History) SetSessionID(id string) {
44+
h.mu.Lock()
45+
h.sessionID = id
46+
h.mu.Unlock()
47+
}
48+
49+
// Add appends text to history. Duplicates are moved to the front.
50+
func (h *History) Add(text string) {
51+
if text == "" {
52+
return
53+
}
54+
h.mu.Lock()
55+
defer h.mu.Unlock()
56+
57+
// Deduplicate: remove existing occurrence, then prepend.
58+
if idx := slices.Index(h.items, text); idx >= 0 {
59+
h.items = slices.Delete(h.items, idx, idx+1)
60+
}
61+
h.items = slices.Insert(h.items, 0, text)
62+
if len(h.items) > maxHistoryItems {
63+
h.items = h.items[:maxHistoryItems]
64+
}
65+
66+
h.appendFile(text)
67+
}
68+
69+
// Get returns the history entry at index (0 = most recent).
70+
func (h *History) Get(index int) string {
71+
h.mu.Lock()
72+
defer h.mu.Unlock()
73+
if index < 0 || index >= len(h.items) {
74+
return ""
75+
}
76+
return h.items[index]
77+
}
78+
79+
// Len returns the number of history entries for the current project.
80+
func (h *History) Len() int {
81+
h.mu.Lock()
82+
defer h.mu.Unlock()
83+
return len(h.items)
84+
}
85+
86+
// load reads the JSONL file and populates items for the current project.
87+
func (h *History) load() {
88+
f, err := os.Open(h.path)
89+
if err != nil {
90+
return
91+
}
92+
defer f.Close()
93+
94+
seen := make(map[string]struct{})
95+
var all []string
96+
97+
scanner := bufio.NewScanner(f)
98+
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
99+
for scanner.Scan() {
100+
var e HistoryEntry
101+
if json.Unmarshal(scanner.Bytes(), &e) != nil {
102+
continue
103+
}
104+
if e.Project != h.project || e.Display == "" {
105+
continue
106+
}
107+
all = append(all, e.Display)
108+
}
109+
110+
// Reverse so newest is first, then deduplicate.
111+
slices.Reverse(all)
112+
for _, text := range all {
113+
if _, ok := seen[text]; ok {
114+
continue
115+
}
116+
seen[text] = struct{}{}
117+
h.items = append(h.items, text)
118+
if len(h.items) >= maxHistoryItems {
119+
break
120+
}
121+
}
122+
}
123+
124+
// appendFile appends a single entry to the JSONL file.
125+
func (h *History) appendFile(text string) {
126+
_ = os.MkdirAll(filepath.Dir(h.path), 0o755)
127+
f, err := os.OpenFile(h.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
128+
if err != nil {
129+
return
130+
}
131+
defer f.Close()
132+
133+
e := HistoryEntry{
134+
Display: text,
135+
PastedContents: map[string]any{},
136+
Timestamp: time.Now().UnixMilli(),
137+
Project: h.project,
138+
SessionID: h.sessionID,
139+
}
140+
data, err := json.Marshal(e)
141+
if err != nil {
142+
return
143+
}
144+
data = append(data, '\n')
145+
_, _ = f.Write(data)
146+
}

internal/ui/app.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ type App struct {
3535
// PlanStore persists plans to <cwd>/.codebot/plans/.
3636
PlanStore *storage.PlanStore
3737

38+
// History provides input history for Up/Down navigation.
39+
History *storage.History
40+
3841
// Plan mode state.
3942
planState planState
4043
planContent string // free-form plan text from LLM
@@ -51,6 +54,7 @@ func (a *App) Config() tui.Config {
5154
return tui.Config{
5255
Cwd: a.Cwd,
5356
GitBranch: a.GitBranch,
57+
History: a.History,
5458
OnKey: a.onKey(),
5559
OnPaste: a.onPaste,
5660
OnDrop: a.onDrop,

internal/ui/tui/model.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/charmbracelet/glamour"
1313
"github.com/charmbracelet/lipgloss"
1414
"github.com/voocel/agentcore"
15+
"github.com/voocel/codebot/internal/storage" // input history
1516
"github.com/voocel/codebot/internal/tools"
1617
)
1718

@@ -32,6 +33,7 @@ type Config struct {
3233
Placeholder string
3334
Cwd string
3435
GitBranch string
36+
History *storage.History // input history (Up/Down navigation)
3537
OnKey func(m *Model, msg tea.KeyMsg) (handled bool, cmd tea.Cmd)
3638
OnEvent func(m *Model, ev agentcore.Event) tea.Cmd
3739
OnPaste func(m *Model) tea.Cmd // Ctrl+V: read clipboard image, return ImageAttachedMsg
@@ -100,6 +102,10 @@ type Model struct {
100102
QueuedMsgs []string // messages queued while agent is running (display only)
101103

102104
QuitPending bool // true after first Ctrl+C, waiting for second to quit
105+
106+
history *storage.History // input history store (nil = disabled)
107+
histIdx int // -1 = not browsing; 0+ = current position (0 = most recent)
108+
histDraft string // stashed input before history navigation
103109
}
104110

105111
// New creates a Model with the given agent, model name, and optional config.
@@ -154,6 +160,8 @@ func New(driver Driver, modelName string, cfg ...Config) Model {
154160
ShowWelcome: true,
155161
ImageCursor: -1,
156162
config: c,
163+
history: c.History,
164+
histIdx: -1,
157165
}
158166
}
159167

@@ -440,6 +448,12 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
440448
m.Input.SetHeight(1)
441449
return m, nil
442450
}
451+
if m.history != nil && text != "" {
452+
m.history.Add(text)
453+
}
454+
m.histIdx = -1
455+
m.histDraft = ""
456+
443457
images := m.Images
444458
m.Images = nil
445459
m.ImageCursor = -1
@@ -480,6 +494,38 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
480494
m.ImageCursor = len(m.Images) - 1
481495
return m, nil
482496
}
497+
// History navigation: cursor on first line + history available.
498+
if m.history != nil && m.history.Len() > 0 && m.Input.Line() == 0 && !m.Running {
499+
if m.histIdx == -1 {
500+
m.histDraft = m.Input.Value()
501+
m.histIdx = 0
502+
} else if m.histIdx < m.history.Len()-1 {
503+
m.histIdx++
504+
}
505+
m.Input.Reset()
506+
m.Input.SetValue(m.history.Get(m.histIdx))
507+
m.Input.CursorEnd()
508+
m.adjustInputHeight()
509+
return m, nil
510+
}
511+
512+
case "down":
513+
// History navigation: browsing history + cursor on last line.
514+
if m.histIdx >= 0 && m.Input.Line() == m.Input.LineCount()-1 {
515+
if m.histIdx > 0 {
516+
m.histIdx--
517+
m.Input.Reset()
518+
m.Input.SetValue(m.history.Get(m.histIdx))
519+
} else {
520+
m.histIdx = -1
521+
m.Input.Reset()
522+
m.Input.SetValue(m.histDraft)
523+
m.histDraft = ""
524+
}
525+
m.Input.CursorEnd()
526+
m.adjustInputHeight()
527+
return m, nil
528+
}
483529
}
484530

485531
var cmd tea.Cmd

internal/ui/tui_mode.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package ui
33
import (
44
"context"
55
"fmt"
6+
"path/filepath"
67

78
tea "github.com/charmbracelet/bubbletea"
89
"github.com/voocel/codebot/internal/agent"
@@ -25,6 +26,7 @@ func RunTUI(sess *agent.Session, cwd, gitBranch, modelName string, profile polic
2526
Skills: sess.Skills(),
2627
PlanStore: storage.NewPlanStore(config.PlansDir(cwd)),
2728
MCPManager: mcpMgr,
29+
History: newInputHistory(sess, cwd),
2830
}
2931

3032
m := tui.New(sess, modelName, adapter.Config())
@@ -80,3 +82,16 @@ func RunTUI(sess *agent.Session, cwd, gitBranch, modelName string, profile polic
8082
}
8183
return nil
8284
}
85+
86+
// newInputHistory creates a History scoped to the current session and project.
87+
func newInputHistory(sess *agent.Session, cwd string) *storage.History {
88+
var sessionID string
89+
if info, err := sess.CurrentSessionInfo(); err == nil {
90+
sessionID = info.ID
91+
}
92+
return storage.NewHistory(
93+
filepath.Join(config.UserConfigDir(), "history.jsonl"),
94+
cwd,
95+
sessionID,
96+
)
97+
}

0 commit comments

Comments
 (0)