Skip to content

Commit e4f2c9f

Browse files
committed
test message
1 parent 1821f27 commit e4f2c9f

13 files changed

Lines changed: 545 additions & 47 deletions

File tree

cmd/iterate/config.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
type iterConfig struct {
1717
Provider string `json:"provider" toml:"provider"`
1818
Model string `json:"model" toml:"model"`
19+
APIKey string `json:"api_key,omitempty" toml:"api_key"`
1920
OllamaBaseURL string `json:"ollama_base_url,omitempty" toml:"ollama_base_url"`
2021
SafeMode bool `json:"safe_mode,omitempty" toml:"safe_mode"`
2122
DeniedTools []string `json:"denied_tools,omitempty" toml:"denied_tools"`
@@ -34,6 +35,19 @@ type iterConfig struct {
3435
DenyDirs []string `json:"deny_dirs,omitempty" toml:"deny_dirs"`
3536
}
3637

38+
func (c *iterConfig) GetAPIKey() string { return c.APIKey }
39+
func (c *iterConfig) SetAPIKey(v string) { c.APIKey = v }
40+
func (c *iterConfig) SetProvider(v string) { c.Provider = v }
41+
func (c *iterConfig) SetModel(v string) { c.Model = v }
42+
func (c *iterConfig) SetNotify(v bool) { c.Notify = v }
43+
func (c *iterConfig) SetSafeMode(v bool) { c.SafeMode = v }
44+
func (c *iterConfig) SetTheme(v string) { c.Theme = v }
45+
func (c *iterConfig) SetThinkingLevel(v string) { c.ThinkingLevel = v }
46+
func (c *iterConfig) SetTemperature(v float64) { c.Temperature = v }
47+
func (c *iterConfig) SetMaxTokens(v int) { c.MaxTokens = v }
48+
func (c *iterConfig) SetCacheEnabled(v bool) { c.CacheEnabled = v }
49+
func (c *iterConfig) SetOllamaBaseURL(v string) { c.OllamaBaseURL = v }
50+
3751
func configPath() string {
3852
home, _ := os.UserHomeDir()
3953
return filepath.Join(home, ".iterate", "config.json")
@@ -130,7 +144,7 @@ func saveConfig(cfg iterConfig) {
130144
}
131145
var buf bytes.Buffer
132146
if err := toml.NewEncoder(&buf).Encode(cfg); err == nil {
133-
if err := os.WriteFile(tomlPath, buf.Bytes(), 0o644); err != nil {
147+
if err := atomicWriteFile(tomlPath, buf.Bytes(), 0o644); err != nil {
134148
slog.Warn("failed to write TOML config", "err", err)
135149
}
136150
return
@@ -141,7 +155,7 @@ func saveConfig(cfg iterConfig) {
141155
slog.Warn("failed to create JSON config dir", "err", err)
142156
}
143157
data, _ := json.MarshalIndent(cfg, "", " ")
144-
if err := os.WriteFile(jsonPath, data, 0o644); err != nil {
158+
if err := atomicWriteFile(jsonPath, data, 0o644); err != nil {
145159
slog.Warn("failed to write JSON config", "err", err)
146160
}
147161
}

cmd/iterate/features_sessions.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func saveSession(name string, messages []iteragent.Message) error {
6060
if err != nil {
6161
return err
6262
}
63-
return os.WriteFile(path, data, 0o644)
63+
return atomicWriteFile(path, data, 0o644)
6464
}
6565

6666
func loadSession(name string) ([]iteragent.Message, error) {
@@ -117,7 +117,7 @@ func loadBookmarks() []Bookmark {
117117

118118
func saveBookmarks(bms []Bookmark) {
119119
data, _ := json.MarshalIndent(bms, "", " ")
120-
if err := os.WriteFile(bookmarksPath(), data, 0o644); err != nil {
120+
if err := atomicWriteFile(bookmarksPath(), data, 0o644); err != nil {
121121
slog.Warn("failed to write bookmarks file", "err", err)
122122
}
123123
}

cmd/iterate/fileutil.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
)
8+
9+
// atomicWriteFile writes data to path atomically by writing to a temp file in
10+
// the same directory then renaming it. This prevents partial writes from
11+
// corrupting the file if the process is interrupted mid-write.
12+
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
13+
dir := filepath.Dir(path)
14+
tmp, err := os.CreateTemp(dir, ".tmp-")
15+
if err != nil {
16+
return fmt.Errorf("create temp file: %w", err)
17+
}
18+
tmpName := tmp.Name()
19+
defer func() {
20+
// Clean up temp file if rename didn't happen.
21+
_ = os.Remove(tmpName)
22+
}()
23+
24+
if _, err := tmp.Write(data); err != nil {
25+
_ = tmp.Close()
26+
return fmt.Errorf("write temp file: %w", err)
27+
}
28+
if err := tmp.Chmod(perm); err != nil {
29+
_ = tmp.Close()
30+
return fmt.Errorf("chmod temp file: %w", err)
31+
}
32+
if err := tmp.Close(); err != nil {
33+
return fmt.Errorf("close temp file: %w", err)
34+
}
35+
if err := os.Rename(tmpName, path); err != nil {
36+
return fmt.Errorf("rename temp file to %s: %w", path, err)
37+
}
38+
return nil
39+
}

cmd/iterate/main_mode.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ import (
1616

1717
func runMode(ctx context.Context, f mainFlags, absRepo string, logger *slog.Logger) {
1818
cfg := loadConfig()
19-
providerName, modelName := resolveProviderConfig(f.provider, f.model, cfg)
19+
providerName, modelName, apiKey := resolveProviderConfig(f.provider, f.model, f.apiKey, cfg)
2020
f.provider = providerName
2121
f.model = modelName
22+
f.apiKey = apiKey
2223

2324
p, err := initProvider(f.provider, f.apiKey, logger)
2425
if err != nil {

cmd/iterate/memory_project.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ func saveProjectMemory(repoPath string, m projectMemory) error {
5151
if err != nil {
5252
return err
5353
}
54-
return os.WriteFile(path, data, 0o644)
54+
return atomicWriteFile(path, data, 0o644)
5555
}
5656

5757
func addProjectMemoryNote(repoPath, note string) error {

cmd/iterate/provider.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,28 @@ import (
99

1010
// resolveProviderConfig merges flag values with persisted config.
1111
// Flags take precedence: only defaults ("gemini", empty) are overridden.
12-
func resolveProviderConfig(flagProvider, flagModel string, cfg iterConfig) (provider, model string) {
12+
func resolveProviderConfig(flagProvider, flagModel, flagAPIKey string, cfg iterConfig) (provider, model, apiKey string) {
1313
provider = flagProvider
1414
model = flagModel
15+
apiKey = flagAPIKey
1516

1617
if provider == "gemini" && cfg.Provider != "" && cfg.Provider != "gemini" {
1718
provider = cfg.Provider
1819
}
1920
if model == "" && cfg.Model != "" {
2021
model = cfg.Model
2122
}
23+
if apiKey == "" && cfg.APIKey != "" {
24+
apiKey = cfg.APIKey
25+
}
2226
if cfg.OllamaBaseURL != "" && os.Getenv("OLLAMA_BASE_URL") == "" {
2327
os.Setenv("OLLAMA_BASE_URL", cfg.OllamaBaseURL)
2428
}
2529
if model != "" {
2630
os.Setenv("ITERATE_MODEL", model)
2731
}
2832

29-
return provider, model
33+
return provider, model, apiKey
3034
}
3135

3236
// resolveThinkingLevel returns the effective thinking level, falling back to

cmd/iterate/repl.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ var replRegistry = commands.DefaultRegistry()
4141
// iterateVersion is the current version string.
4242
const iterateVersion = "dev"
4343

44+
// closeProvider shuts down a provider if it implements io.Closer.
45+
func closeProvider(p iteragent.Provider) {
46+
type closer interface{ Close() error }
47+
if c, ok := p.(closer); ok {
48+
_ = c.Close()
49+
}
50+
}
51+
4452
func makeAgent(p iteragent.Provider, repoPath string, thinking iteragent.ThinkingLevel, logger *slog.Logger) *iteragent.Agent {
4553
base := iteragent.DefaultTools(repoPath)
4654
switch currentMode {
@@ -181,7 +189,10 @@ func runREPL(ctx context.Context, p iteragent.Provider, repoPath string, thinkin
181189
thinking = initREPL(repoPath, thinking)
182190

183191
a := makeAgent(p, repoPath, thinking, logger)
184-
defer func() { _ = a.Close() }() // best-effort cleanup
192+
defer func() {
193+
_ = a.Close()
194+
closeProvider(p)
195+
}()
185196

186197
printHeader(p, thinking, repoPath)
187198

@@ -262,6 +273,7 @@ func handleModelProviderSwitch(line string, p *iteragent.Provider, thinking *ite
262273
slog.Error("provider switch failed", "provider", providerName, "error", err)
263274
fmt.Printf("%serror: %s%s\n\n", colorRed, err, colorReset)
264275
} else {
276+
closeProvider(*p)
265277
*p = newP
266278
os.Setenv("ITERATE_PROVIDER", providerName)
267279
_ = (*a).Close() // best-effort cleanup
@@ -422,6 +434,17 @@ func buildCommandContext(repoPath, line string, parts []string, p iteragent.Prov
422434
InputHistory: selector.InputHistoryRef,
423435
StopWatch: stopWatch,
424436
Pool: agentPool,
437+
Config: commands.ConfigCallbacks{
438+
LoadConfig: func() interface{} {
439+
c := loadConfig()
440+
return &c
441+
},
442+
SaveConfig: func(v interface{}) {
443+
if c, ok := v.(*iterConfig); ok {
444+
saveConfig(*c)
445+
}
446+
},
447+
},
425448
Session: commands.SessionCallbacks{
426449
SaveSession: saveSession,
427450
LoadSession: loadSession,

cmd/iterate/repl_streaming.go

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ func logTokenDelta(beforeTokens int) {
102102
func streamAndPrint(ctx context.Context, a *iteragent.Agent, prompt string, repoPath string) {
103103
recordMessage()
104104

105+
// Sync pinned messages into the agent before each request.
106+
a.SetPinnedMessages(getPinnedMessages())
107+
105108
reqCtx, cancel := context.WithCancel(ctx)
106109
sess.RequestCancel = cancel
107110
defer func() {
@@ -118,9 +121,13 @@ func streamAndPrint(ctx context.Context, a *iteragent.Agent, prompt string, repo
118121

119122
var fullContent string
120123
var toolStart time.Time
124+
var ttft time.Duration
121125
beforeTokens := sess.Tokens
122126

123127
for e := range events {
128+
if ttft == 0 && iteragent.EventType(e.Type) == iteragent.EventTokenUpdate && e.Content != "" {
129+
ttft = time.Since(start).Round(time.Millisecond)
130+
}
124131
fullContent, toolStart = processStreamEvent(e, fullContent, toolStart, stopOnce, newSpinner, repoPath)
125132
}
126133
a.Finish()
@@ -135,7 +142,12 @@ func streamAndPrint(ctx context.Context, a *iteragent.Agent, prompt string, repo
135142
elapsed := time.Since(start).Round(time.Millisecond)
136143

137144
updateSessionTokens(a, fullContent)
138-
printFinalStats(elapsed, beforeTokens, fullContent)
145+
printFinalStats(elapsed, ttft, beforeTokens, fullContent)
146+
147+
// Autosave after each turn so a crash doesn't lose the session.
148+
if len(a.Messages) > 0 {
149+
_ = saveSession("autosave", a.Messages)
150+
}
139151
}
140152

141153
// newSpinnerController creates a spinner control pair (stopOnce, newSpinner).
@@ -147,6 +159,7 @@ func newSpinnerController() (func(), func(string)) {
147159
stopOnce func()
148160
spinnerLabel string
149161
)
162+
stopOnce = func() {} // no-op until a spinner is started
150163
newSpinner := func(label string) {
151164
spinnerLabel = label
152165
stopSpinner = make(chan struct{})
@@ -161,7 +174,9 @@ func newSpinnerController() (func(), func(string)) {
161174
go spinner(stopSpinner, spinnerDone, spinnerLabel)
162175
}
163176
_ = spinnerLabel
164-
return stopOnce, newSpinner
177+
// Return a wrapper that always calls the *current* stopOnce, not the
178+
// initial no-op captured at return time.
179+
return func() { stopOnce() }, newSpinner
165180
}
166181

167182
// processStreamEvent handles a single agent stream event and returns updated state.
@@ -184,7 +199,15 @@ func processStreamEvent(e iteragent.Event, fullContent string, toolStart time.Ti
184199
fmt.Printf("%s%s %s%s", col, icon, label, colorReset)
185200
case iteragent.EventToolExecutionEnd:
186201
elapsed := time.Since(toolStart).Round(time.Millisecond)
187-
printToolResult(e.Result, elapsed, e.ToolName, repoPath)
202+
if e.IsError {
203+
fmt.Printf("%s ✗ %s%s %s%s%s\n",
204+
colorDim, colorReset,
205+
colorRed, e.Result, colorReset,
206+
colorDim)
207+
fmt.Print(colorReset)
208+
} else {
209+
printToolResult(e.Result, elapsed, e.ToolName, repoPath)
210+
}
188211
newSpinner("thinking")
189212
case iteragent.EventContextCompacted:
190213
fmt.Printf("\r\033[K%s[context compacted]%s\n", colorDim, colorReset)
@@ -202,34 +225,38 @@ func printToolResult(result string, elapsed time.Duration, toolName string, repo
202225
}
203226
}
204227

205-
// updateSessionTokens updates session token counters from the last agent message.
228+
// updateSessionTokens updates session token counters from the last assistant
229+
// message with usage data. Searches backwards since tool result messages (role
230+
// user) may appear after the final assistant message.
206231
func updateSessionTokens(a *iteragent.Agent, fullContent string) {
207-
if len(a.Messages) > 0 {
208-
last := a.Messages[len(a.Messages)-1]
209-
if last.Usage != nil {
210-
sess.InputTokens += last.Usage.InputTokens
211-
sess.OutputTokens += last.Usage.OutputTokens
212-
sess.CacheRead += last.Usage.CacheRead
213-
sess.CacheWrite += last.Usage.CacheWrite
214-
sess.Tokens += last.Usage.TotalTokens
232+
for i := len(a.Messages) - 1; i >= 0; i-- {
233+
if a.Messages[i].Usage != nil {
234+
u := a.Messages[i].Usage
235+
sess.InputTokens += u.InputTokens
236+
sess.OutputTokens += u.OutputTokens
237+
sess.CacheRead += u.CacheRead
238+
sess.CacheWrite += u.CacheWrite
239+
sess.Tokens += u.TotalTokens
240+
return
215241
}
216-
} else {
217-
approxTokens := len(fullContent) / 4
218-
sess.Tokens += approxTokens
219-
sess.OutputTokens += approxTokens
220242
}
243+
// Fallback: approximate from streamed content length.
244+
approxTokens := len(fullContent) / 4
245+
sess.Tokens += approxTokens
246+
sess.OutputTokens += approxTokens
221247
}
222248

223-
// printFinalStats prints token delta, status line, and debug log.
224-
func printFinalStats(elapsed time.Duration, beforeTokens int, fullContent string) {
225-
fmt.Println()
226-
logTokenDelta(beforeTokens)
249+
// printFinalStats prints the status line and debug log.
250+
func printFinalStats(elapsed, ttft time.Duration, beforeTokens int, fullContent string) {
251+
delta := sess.Tokens - beforeTokens
252+
227253
fmt.Println()
228254
selector.InputTokens = sess.InputTokens
229255
selector.OutputTokens = sess.OutputTokens
230256
selector.SafeMode = cfg.SafeMode
231-
selector.PrintStatusLine(elapsed)
257+
selector.TTFT = ttft
258+
selector.PrintStatusLine(elapsed, delta)
232259
fmt.Println()
233260

234-
slog.Debug("request completed", "elapsed_ms", elapsed.Milliseconds(), "response_chars", len(fullContent), "total_tokens", sess.Tokens)
261+
slog.Debug("request completed", "elapsed_ms", elapsed.Milliseconds(), "ttft_ms", ttft.Milliseconds(), "response_chars", len(fullContent), "total_tokens", sess.Tokens)
235262
}

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ require (
1111
golang.org/x/tools v0.43.0
1212
)
1313

14+
replace github.com/GrayCodeAI/iteragent => ../iteragent
15+
1416
require (
1517
github.com/google/go-querystring v1.2.0 // indirect
1618
golang.org/x/mod v0.34.0 // indirect

go.sum

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
22
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3-
github.com/GrayCodeAI/iteragent v1.4.0 h1:BSW/Zj0142sXO6vZrC47LUqLt5O3M6eepgpG+VEDMxk=
4-
github.com/GrayCodeAI/iteragent v1.4.0/go.mod h1:wsIv7o17FthxfJ1zzbqgPdnVSSlUOxmEC2ch96S+4hY=
5-
github.com/GrayCodeAI/iteragent v1.5.0 h1:Z7weM5U6SOgFBf6AH2clSxPGeXB0URxGXF1kfhqhpG4=
6-
github.com/GrayCodeAI/iteragent v1.5.0/go.mod h1:wsIv7o17FthxfJ1zzbqgPdnVSSlUOxmEC2ch96S+4hY=
73
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
84
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
95
github.com/google/go-github/v61 v61.0.0 h1:VwQCBwhyE9JclCI+22/7mLB1PuU9eowCXKY5pNlu1go=

0 commit comments

Comments
 (0)