Skip to content

Commit fa16b2d

Browse files
yanmxaclaude
andcommitted
feat: add prompt suggestion ghost text and UI improvements
- Add prompt suggestion: predict next user input after conversation turns, display as ghost text (Tab to accept, Esc to dismiss) - Fix plugin navigation: disable vim j/k keys during search to avoid conflict - Improve tool result box width: cap at 80% of screen width - Auto-trim pasted text whitespace - Refine exit message styling with dim text - Adjust command suggestion box and welcome banner layout Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> Signed-off-by: Meng Yan <myan@redhat.com>
1 parent 0bdc367 commit fa16b2d

File tree

12 files changed

+302
-51
lines changed

12 files changed

+302
-51
lines changed

internal/app/conversation/conversation.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,17 @@ func (m *Model) HasAllToolResults(idx int) bool {
113113

114114
// ConvertToProvider converts chat messages to provider format, skipping notices.
115115
func (m Model) ConvertToProvider() []message.Message {
116-
providerMsgs := make([]message.Message, 0, len(m.Messages))
117-
for _, msg := range m.Messages {
116+
return m.ConvertToProviderFrom(0)
117+
}
118+
119+
// ConvertToProviderFrom converts chat messages starting from startIdx to provider format.
120+
func (m Model) ConvertToProviderFrom(startIdx int) []message.Message {
121+
if startIdx < 0 {
122+
startIdx = 0
123+
}
124+
providerMsgs := make([]message.Message, 0, len(m.Messages)-startIdx)
125+
for i := startIdx; i < len(m.Messages); i++ {
126+
msg := m.Messages[i]
118127
if msg.Role == message.RoleNotice {
119128
continue
120129
}

internal/app/handler_input.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,15 @@ func (m *model) handleSuggestionKey(msg tea.KeyMsg) (tea.Cmd, bool) {
9797
// handleInputKey handles general input keys (shortcuts, navigation, submit).
9898
func (m *model) handleInputKey(msg tea.KeyMsg) (tea.Cmd, bool) {
9999
switch msg.Type {
100+
case tea.KeyTab:
101+
// Accept ghost text suggestion
102+
if m.promptSuggestion.text != "" && m.input.Textarea.Value() == "" {
103+
m.input.Textarea.SetValue(m.promptSuggestion.text)
104+
m.input.Textarea.CursorEnd()
105+
m.promptSuggestion.Clear()
106+
return nil, true
107+
}
108+
100109
case tea.KeyShiftTab:
101110
if !m.conv.Stream.Active && !m.approval.IsActive() &&
102111
!m.mode.Question.IsActive() &&
@@ -143,6 +152,10 @@ func (m *model) handleInputKey(msg tea.KeyMsg) (tea.Cmd, bool) {
143152
return tea.Quit, true
144153

145154
case tea.KeyEsc:
155+
if m.promptSuggestion.text != "" {
156+
m.promptSuggestion.Clear()
157+
return nil, true
158+
}
146159
if m.input.Suggestions.IsVisible() {
147160
m.input.Suggestions.Hide()
148161
return nil, true
@@ -361,6 +374,7 @@ func (m *model) handleHistoryDown() tea.Cmd {
361374
}
362375

363376
func (m *model) handleSubmit() tea.Cmd {
377+
m.promptSuggestion.Clear()
364378
if m.conv.Stream.Active {
365379
return nil
366380
}

internal/app/handler_stream.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ func (m *model) handleStreamDone(msg appconv.ChunkMsg) tea.Cmd {
7777

7878
if m.shouldAutoCompact() {
7979
commitCmds = append(commitCmds, m.triggerAutoCompact())
80+
} else {
81+
// Generate prompt suggestion in background
82+
if cmd := m.startPromptSuggestion(); cmd != nil {
83+
commitCmds = append(commitCmds, cmd)
84+
}
8085
}
8186

8287
return tea.Batch(commitCmds...)

internal/app/model.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,10 @@ type model struct {
8484
initialPrompt string // initial prompt from CLI args
8585

8686
// Config and Infra
87-
settings *config.Settings
88-
hookEngine *hooks.Engine
89-
loop *core.Loop
90-
startTime time.Time
87+
settings *config.Settings
88+
hookEngine *hooks.Engine
89+
loop *core.Loop
90+
promptSuggestion promptSuggestionState
9191
}
9292

9393
// --- Constructor and Init ---
@@ -162,7 +162,6 @@ func newModel(opts options.RunOptions) (model, error) {
162162
settings: settings,
163163
hookEngine: hookEngine,
164164
loop: &core.Loop{},
165-
startTime: time.Now(),
166165
}
167166

168167
// Apply run options

internal/app/plugin/model.go

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,7 +1067,7 @@ func (s *Model) handleAddMarketplaceKeypress(key tea.KeyMsg) tea.Cmd {
10671067
}
10681068

10691069
func (s *Model) handleDetailKeypress(key tea.KeyMsg) tea.Cmd {
1070-
if s.handleNavigationKey(key) {
1070+
if s.handleNavigationKey(key, true) {
10711071
return nil
10721072
}
10731073
switch key.Type {
@@ -1084,7 +1084,7 @@ func (s *Model) handleDetailKeypress(key tea.KeyMsg) tea.Cmd {
10841084
}
10851085

10861086
func (s *Model) handleBrowseKeypress(key tea.KeyMsg) tea.Cmd {
1087-
if s.handleNavigationKey(key) {
1087+
if s.handleNavigationKey(key, true) {
10881088
return nil
10891089
}
10901090
switch key.Type {
@@ -1102,8 +1102,9 @@ func (s *Model) handleBrowseKeypress(key tea.KeyMsg) tea.Cmd {
11021102
return nil
11031103
}
11041104

1105-
// handleNavigationKey handles common up/down navigation keys, returns true if handled
1106-
func (s *Model) handleNavigationKey(key tea.KeyMsg) bool {
1105+
// handleNavigationKey handles common up/down navigation keys, returns true if handled.
1106+
// When vimKeys is true, j/k are also recognized as down/up.
1107+
func (s *Model) handleNavigationKey(key tea.KeyMsg, vimKeys bool) bool {
11071108
switch key.Type {
11081109
case tea.KeyUp, tea.KeyCtrlP:
11091110
s.MoveUp()
@@ -1112,13 +1113,15 @@ func (s *Model) handleNavigationKey(key tea.KeyMsg) bool {
11121113
s.MoveDown()
11131114
return true
11141115
case tea.KeyRunes:
1115-
switch key.String() {
1116-
case "k":
1117-
s.MoveUp()
1118-
return true
1119-
case "j":
1120-
s.MoveDown()
1121-
return true
1116+
if vimKeys {
1117+
switch key.String() {
1118+
case "k":
1119+
s.MoveUp()
1120+
return true
1121+
case "j":
1122+
s.MoveDown()
1123+
return true
1124+
}
11221125
}
11231126
}
11241127
return false
@@ -1137,8 +1140,8 @@ func (s *Model) handleListKeypress(key tea.KeyMsg) tea.Cmd {
11371140
}
11381141
}
11391142

1140-
// Handle common navigation keys
1141-
if s.handleNavigationKey(key) {
1143+
// Handle common navigation keys (skip j/k when searching)
1144+
if s.handleNavigationKey(key, s.searchQuery == "") {
11421145
return nil
11431146
}
11441147

internal/app/prompt_suggest.go

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package app
2+
3+
import (
4+
"context"
5+
"regexp"
6+
"strings"
7+
8+
tea "github.com/charmbracelet/bubbletea"
9+
10+
"github.com/yanmxa/gencode/internal/message"
11+
)
12+
13+
// promptSuggestionMsg carries the result of a background suggestion generation.
14+
type promptSuggestionMsg struct {
15+
text string
16+
err error
17+
}
18+
19+
// promptSuggestionState holds ghost text suggestion state.
20+
type promptSuggestionState struct {
21+
text string
22+
cancel context.CancelFunc
23+
}
24+
25+
func (s *promptSuggestionState) Clear() {
26+
s.text = ""
27+
if s.cancel != nil {
28+
s.cancel()
29+
s.cancel = nil
30+
}
31+
}
32+
33+
const suggestionSystemPrompt = `You predict what the user will type next in a coding assistant CLI.
34+
Reply with ONLY the predicted text (2-12 words). No quotes, no explanation.
35+
If unsure, reply with nothing.`
36+
37+
const suggestionUserPrompt = `[PREDICTION MODE] Based on this conversation, predict what the user will type next.
38+
Stay silent if the next step isn't obvious. Match the user's language and style.`
39+
40+
const maxSuggestionMessages = 20
41+
42+
// startPromptSuggestion launches a background API call to generate a prompt suggestion.
43+
func (m *model) startPromptSuggestion() tea.Cmd {
44+
if m.loop.Client == nil {
45+
return nil
46+
}
47+
48+
// Need at least 2 assistant messages for meaningful suggestion
49+
assistantCount := 0
50+
for _, msg := range m.conv.Messages {
51+
if msg.Role == message.RoleAssistant {
52+
assistantCount++
53+
}
54+
}
55+
if assistantCount < 2 {
56+
return nil
57+
}
58+
59+
// Cancel any prior in-flight suggestion
60+
m.promptSuggestion.Clear()
61+
62+
ctx, cancel := context.WithCancel(context.Background())
63+
m.promptSuggestion.cancel = cancel
64+
65+
// Convert only the last N messages to keep the API call lightweight
66+
startIdx := 0
67+
if len(m.conv.Messages) > maxSuggestionMessages {
68+
startIdx = len(m.conv.Messages) - maxSuggestionMessages
69+
}
70+
msgs := m.conv.ConvertToProviderFrom(startIdx)
71+
72+
// Append prediction request
73+
msgs = append(msgs, message.Message{
74+
Role: message.RoleUser,
75+
Content: suggestionUserPrompt,
76+
})
77+
78+
client := m.loop.Client
79+
80+
return func() tea.Msg {
81+
resp, err := client.Complete(ctx, suggestionSystemPrompt, msgs, 60)
82+
if err != nil {
83+
return promptSuggestionMsg{err: err}
84+
}
85+
return promptSuggestionMsg{text: resp.Content}
86+
}
87+
}
88+
89+
// handlePromptSuggestion processes the suggestion result.
90+
func (m *model) handlePromptSuggestion(msg promptSuggestionMsg) {
91+
if msg.err != nil {
92+
return
93+
}
94+
// Discard if user already started typing
95+
if m.input.Textarea.Value() != "" {
96+
return
97+
}
98+
// Discard if streaming is active
99+
if m.conv.Stream.Active {
100+
return
101+
}
102+
if text := filterSuggestion(msg.text); text != "" {
103+
m.promptSuggestion.text = text
104+
}
105+
}
106+
107+
var (
108+
aiVoicePrefixes = []string{
109+
"i'll", "i will", "let me", "here's", "here is", "here are",
110+
"i can", "i would", "i think", "i notice", "i'm",
111+
"that's", "this is", "this will",
112+
"you can", "you should", "you could",
113+
"sure,", "of course", "certainly",
114+
}
115+
evaluativePhrases = []string{
116+
"thanks", "thank you", "looks good", "sounds good",
117+
"that works", "that worked", "that's all",
118+
"nice", "great", "perfect", "makes sense",
119+
"awesome", "excellent", "good job",
120+
}
121+
prefixedLabelRe = regexp.MustCompile(`^\w+:\s`)
122+
multipleSentencesRe = regexp.MustCompile(`[.!?]\s+[A-Z]`)
123+
)
124+
125+
// filterSuggestion validates and cleans a suggestion. Returns "" if invalid.
126+
func filterSuggestion(text string) string {
127+
text = strings.TrimSpace(text)
128+
// Remove surrounding quotes if present
129+
if len(text) >= 2 && (text[0] == '"' && text[len(text)-1] == '"' ||
130+
text[0] == '\'' && text[len(text)-1] == '\'') {
131+
text = text[1 : len(text)-1]
132+
text = strings.TrimSpace(text)
133+
}
134+
135+
if text == "" {
136+
return ""
137+
}
138+
if len(text) > 100 {
139+
return ""
140+
}
141+
words := strings.Fields(text)
142+
if len(words) > 12 {
143+
return ""
144+
}
145+
// Reject single words unless they are common short commands
146+
if len(words) < 2 && !isAllowedSingleWord(text) {
147+
return ""
148+
}
149+
// Reject markdown or multi-line
150+
if strings.ContainsAny(text, "*\n") {
151+
return ""
152+
}
153+
// Reject multiple sentences
154+
if multipleSentencesRe.MatchString(text) {
155+
return ""
156+
}
157+
// Reject prefixed labels like "Action: ..."
158+
if prefixedLabelRe.MatchString(text) {
159+
return ""
160+
}
161+
162+
lower := strings.ToLower(text)
163+
164+
// Reject AI voice
165+
for _, prefix := range aiVoicePrefixes {
166+
if strings.HasPrefix(lower, prefix) {
167+
return ""
168+
}
169+
}
170+
// Reject evaluative
171+
for _, phrase := range evaluativePhrases {
172+
if strings.Contains(lower, phrase) {
173+
return ""
174+
}
175+
}
176+
177+
return text
178+
}
179+
180+
var allowedSingleWords = map[string]bool{
181+
"yes": true, "yeah": true, "yep": true, "yup": true,
182+
"no": true, "sure": true, "ok": true, "okay": true,
183+
"push": true, "commit": true, "deploy": true,
184+
"stop": true, "continue": true, "check": true,
185+
"exit": true, "quit": true,
186+
}
187+
188+
func isAllowedSingleWord(word string) bool {
189+
if strings.HasPrefix(word, "/") {
190+
return true
191+
}
192+
return allowedSingleWords[strings.ToLower(word)]
193+
}

internal/app/render/message.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func RenderWelcome() string {
3737
icon := bracketStyle.Render(" < ") +
3838
genStyle.Render("GEN") +
3939
slashStyle.Render(" ✦ ") +
40-
slashStyle.Render("/ ") +
40+
slashStyle.Render("/") +
4141
bracketStyle.Render(">")
4242

4343
return "\n" + icon

internal/app/run.go

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ package app
44
import (
55
"context"
66
"fmt"
7-
"time"
87

98
tea "github.com/charmbracelet/bubbletea"
9+
"github.com/charmbracelet/lipgloss"
1010
"go.uber.org/zap"
1111

1212
"github.com/yanmxa/gencode/internal/agent"
@@ -18,7 +18,6 @@ import (
1818
"github.com/yanmxa/gencode/internal/options"
1919
"github.com/yanmxa/gencode/internal/plugin"
2020
"github.com/yanmxa/gencode/internal/provider"
21-
"github.com/yanmxa/gencode/internal/tool"
2221
_ "github.com/yanmxa/gencode/internal/provider/anthropic"
2322
_ "github.com/yanmxa/gencode/internal/provider/google"
2423
_ "github.com/yanmxa/gencode/internal/provider/openai"
@@ -175,13 +174,13 @@ func loadSettings() *config.Settings {
175174
return settings
176175
}
177176

178-
// printExitMessage prints session duration and resume command after the TUI exits.
177+
// printExitMessage prints resume command after the TUI exits.
179178
func printExitMessage(m model) {
180-
duration := time.Since(m.startTime)
181-
182-
fmt.Printf("\n✻ Worked for %s\n", tool.FormatDuration(duration))
183-
184179
if m.session.CurrentID != "" {
185-
fmt.Printf("\nResume this session with:\n gen -r %s\n\n", m.session.CurrentID)
180+
dim := lipgloss.NewStyle().Foreground(theme.CurrentTheme.TextDim)
181+
fmt.Println()
182+
fmt.Println(dim.Render("Resume this session with:"))
183+
fmt.Println(dim.Render(" gen -r " + m.session.CurrentID))
184+
fmt.Println()
186185
}
187186
}

0 commit comments

Comments
 (0)