orch-code 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +624 -0
- package/cmd/apply.go +111 -0
- package/cmd/auth.go +393 -0
- package/cmd/auth_test.go +100 -0
- package/cmd/diff.go +57 -0
- package/cmd/doctor.go +149 -0
- package/cmd/explain.go +192 -0
- package/cmd/explain_test.go +62 -0
- package/cmd/init.go +100 -0
- package/cmd/interactive.go +1372 -0
- package/cmd/interactive_input.go +45 -0
- package/cmd/interactive_input_test.go +55 -0
- package/cmd/logs.go +72 -0
- package/cmd/model.go +84 -0
- package/cmd/plan.go +149 -0
- package/cmd/provider.go +189 -0
- package/cmd/provider_model_doctor_test.go +91 -0
- package/cmd/root.go +67 -0
- package/cmd/run.go +123 -0
- package/cmd/run_engine.go +208 -0
- package/cmd/run_engine_test.go +30 -0
- package/cmd/session.go +589 -0
- package/cmd/session_helpers.go +54 -0
- package/cmd/session_integration_test.go +30 -0
- package/cmd/session_list_current_test.go +87 -0
- package/cmd/session_messages_test.go +163 -0
- package/cmd/session_runs_test.go +68 -0
- package/cmd/sprint1_integration_test.go +119 -0
- package/cmd/stats.go +173 -0
- package/cmd/stats_test.go +71 -0
- package/cmd/version.go +4 -0
- package/go.mod +45 -0
- package/go.sum +108 -0
- package/internal/agents/agent.go +31 -0
- package/internal/agents/coder.go +167 -0
- package/internal/agents/planner.go +155 -0
- package/internal/agents/reviewer.go +118 -0
- package/internal/agents/runtime.go +25 -0
- package/internal/agents/runtime_test.go +77 -0
- package/internal/auth/account.go +78 -0
- package/internal/auth/oauth.go +523 -0
- package/internal/auth/store.go +287 -0
- package/internal/confidence/policy.go +174 -0
- package/internal/confidence/policy_test.go +71 -0
- package/internal/confidence/scorer.go +253 -0
- package/internal/confidence/scorer_test.go +83 -0
- package/internal/config/config.go +331 -0
- package/internal/config/config_defaults_test.go +138 -0
- package/internal/execution/contract_builder.go +160 -0
- package/internal/execution/contract_builder_test.go +68 -0
- package/internal/execution/plan_compliance.go +161 -0
- package/internal/execution/plan_compliance_test.go +71 -0
- package/internal/execution/retry_directive.go +132 -0
- package/internal/execution/scope_guard.go +69 -0
- package/internal/logger/logger.go +120 -0
- package/internal/models/contracts_test.go +100 -0
- package/internal/models/models.go +269 -0
- package/internal/orchestrator/orchestrator.go +701 -0
- package/internal/orchestrator/orchestrator_retry_test.go +135 -0
- package/internal/orchestrator/review_engine_test.go +50 -0
- package/internal/orchestrator/state.go +42 -0
- package/internal/orchestrator/test_classifier_test.go +68 -0
- package/internal/patch/applier.go +131 -0
- package/internal/patch/applier_test.go +25 -0
- package/internal/patch/parser.go +89 -0
- package/internal/patch/patch.go +60 -0
- package/internal/patch/summary.go +30 -0
- package/internal/patch/validator.go +104 -0
- package/internal/planning/normalizer.go +416 -0
- package/internal/planning/normalizer_test.go +64 -0
- package/internal/providers/errors.go +35 -0
- package/internal/providers/openai/client.go +498 -0
- package/internal/providers/openai/client_test.go +187 -0
- package/internal/providers/provider.go +47 -0
- package/internal/providers/registry.go +32 -0
- package/internal/providers/registry_test.go +57 -0
- package/internal/providers/router.go +52 -0
- package/internal/providers/state.go +114 -0
- package/internal/providers/state_test.go +64 -0
- package/internal/repo/analyzer.go +188 -0
- package/internal/repo/context.go +83 -0
- package/internal/review/engine.go +267 -0
- package/internal/review/engine_test.go +103 -0
- package/internal/runstore/store.go +137 -0
- package/internal/runstore/store_test.go +59 -0
- package/internal/runtime/lock.go +150 -0
- package/internal/runtime/lock_test.go +57 -0
- package/internal/session/compaction.go +260 -0
- package/internal/session/compaction_test.go +36 -0
- package/internal/session/service.go +117 -0
- package/internal/session/service_test.go +113 -0
- package/internal/storage/storage.go +1498 -0
- package/internal/storage/storage_test.go +413 -0
- package/internal/testing/classifier.go +80 -0
- package/internal/testing/classifier_test.go +36 -0
- package/internal/tools/command.go +160 -0
- package/internal/tools/command_test.go +56 -0
- package/internal/tools/file.go +111 -0
- package/internal/tools/git.go +77 -0
- package/internal/tools/invalid_params_test.go +36 -0
- package/internal/tools/policy.go +98 -0
- package/internal/tools/policy_test.go +36 -0
- package/internal/tools/registry_test.go +52 -0
- package/internal/tools/result.go +30 -0
- package/internal/tools/search.go +86 -0
- package/internal/tools/tool.go +94 -0
- package/main.go +9 -0
- package/npm/orch.js +25 -0
- package/package.json +41 -0
- package/scripts/changelog.js +20 -0
- package/scripts/check-release-version.js +21 -0
- package/scripts/lib/release-utils.js +223 -0
- package/scripts/postinstall.js +157 -0
- package/scripts/release.js +52 -0
|
@@ -0,0 +1,1372 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"crypto/rand"
|
|
6
|
+
"encoding/hex"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"fmt"
|
|
9
|
+
"os"
|
|
10
|
+
"os/exec"
|
|
11
|
+
"strings"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
"github.com/charmbracelet/bubbles/key"
|
|
15
|
+
"github.com/charmbracelet/bubbles/spinner"
|
|
16
|
+
"github.com/charmbracelet/bubbles/textarea"
|
|
17
|
+
"github.com/charmbracelet/bubbles/viewport"
|
|
18
|
+
tea "github.com/charmbracelet/bubbletea"
|
|
19
|
+
"github.com/charmbracelet/lipgloss"
|
|
20
|
+
"github.com/furkanbeydemir/orch/internal/auth"
|
|
21
|
+
"github.com/furkanbeydemir/orch/internal/config"
|
|
22
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
23
|
+
"github.com/furkanbeydemir/orch/internal/providers"
|
|
24
|
+
"github.com/furkanbeydemir/orch/internal/providers/openai"
|
|
25
|
+
"github.com/furkanbeydemir/orch/internal/session"
|
|
26
|
+
"github.com/furkanbeydemir/orch/internal/storage"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
type interactiveModel struct {
|
|
30
|
+
viewport viewport.Model
|
|
31
|
+
input textarea.Model
|
|
32
|
+
spinner spinner.Model
|
|
33
|
+
|
|
34
|
+
logs []string
|
|
35
|
+
running bool
|
|
36
|
+
width int
|
|
37
|
+
height int
|
|
38
|
+
|
|
39
|
+
providerLine string
|
|
40
|
+
authLine string
|
|
41
|
+
modelsLine string
|
|
42
|
+
verboseMode bool
|
|
43
|
+
sessionID string
|
|
44
|
+
resumed bool
|
|
45
|
+
cwd string
|
|
46
|
+
|
|
47
|
+
showSuggestions bool
|
|
48
|
+
suggestions []commandEntry
|
|
49
|
+
suggestionIdx int
|
|
50
|
+
|
|
51
|
+
// New modal selection state
|
|
52
|
+
activeModal *modalState
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type modalType int
|
|
56
|
+
|
|
57
|
+
const (
|
|
58
|
+
modalNone modalType = iota
|
|
59
|
+
modalProvider
|
|
60
|
+
modalAuth
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
type modalState struct {
|
|
64
|
+
Type modalType
|
|
65
|
+
Title string
|
|
66
|
+
Choices []choiceEntry
|
|
67
|
+
Index int
|
|
68
|
+
Selected string // The value selected in the previous step (e.g. chosen provider)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type choiceEntry struct {
|
|
72
|
+
ID string
|
|
73
|
+
Text string
|
|
74
|
+
Sub string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
var providersList = []choiceEntry{
|
|
78
|
+
{ID: "openai", Text: "OpenAI", Sub: "(ChatGPT Plus/Pro or API key)"},
|
|
79
|
+
{ID: "github", Text: "GitHub Copilot", Sub: ""},
|
|
80
|
+
{ID: "anthropic", Text: "Anthropic", Sub: "(Claude Max or API key)"},
|
|
81
|
+
{ID: "google", Text: "Google", Sub: ""},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
var authMethods = map[string][]choiceEntry{
|
|
85
|
+
"openai": {
|
|
86
|
+
{ID: "browser", Text: "ChatGPT Pro/Plus (browser)", Sub: ""},
|
|
87
|
+
{ID: "headless", Text: "ChatGPT Pro/Plus (headless)", Sub: ""},
|
|
88
|
+
{ID: "api_key", Text: "Manually enter API Key", Sub: ""},
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type commandEntry struct {
|
|
93
|
+
Name string
|
|
94
|
+
Desc string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
var allCommands = []commandEntry{
|
|
98
|
+
{Name: "/agents", Desc: "Switch agent"},
|
|
99
|
+
{Name: "/auth", Desc: "Login/Logout from provider"},
|
|
100
|
+
{Name: "/connect", Desc: "Connect provider credentials"},
|
|
101
|
+
{Name: "/clear", Desc: "Clear chat history"},
|
|
102
|
+
{Name: "/exit", Desc: "Exit the app"},
|
|
103
|
+
{Name: "/help", Desc: "Show help messages"},
|
|
104
|
+
{Name: "/init", Desc: "Initialize or update project config"},
|
|
105
|
+
{Name: "/model", Desc: "Switch active model"},
|
|
106
|
+
{Name: "/plan", Desc: "Plan a complex task"},
|
|
107
|
+
{Name: "/provider", Desc: "Select or switch provider"},
|
|
108
|
+
{Name: "/run", Desc: "Execute a task with agents"},
|
|
109
|
+
{Name: "/session", Desc: "Manage chat sessions"},
|
|
110
|
+
{Name: "/stats", Desc: "Show usage statistics"},
|
|
111
|
+
{Name: "/verbose", Desc: "Toggle verbose output (on/off)"},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type theme struct {
|
|
115
|
+
header lipgloss.Style
|
|
116
|
+
accent lipgloss.Style
|
|
117
|
+
muted lipgloss.Style
|
|
118
|
+
success lipgloss.Style
|
|
119
|
+
warning lipgloss.Style
|
|
120
|
+
error lipgloss.Style
|
|
121
|
+
panel lipgloss.Style
|
|
122
|
+
command lipgloss.Style
|
|
123
|
+
timeline lipgloss.Style
|
|
124
|
+
statusRun lipgloss.Style
|
|
125
|
+
statusIdle lipgloss.Style
|
|
126
|
+
chip lipgloss.Style
|
|
127
|
+
chipMuted lipgloss.Style
|
|
128
|
+
composerTag lipgloss.Style
|
|
129
|
+
userCard lipgloss.Style
|
|
130
|
+
assistant lipgloss.Style
|
|
131
|
+
noteCard lipgloss.Style
|
|
132
|
+
errorCard lipgloss.Style
|
|
133
|
+
footer lipgloss.Style
|
|
134
|
+
|
|
135
|
+
menuBox lipgloss.Style
|
|
136
|
+
menuItem lipgloss.Style
|
|
137
|
+
menuSelected lipgloss.Style
|
|
138
|
+
menuDesc lipgloss.Style
|
|
139
|
+
|
|
140
|
+
modalBox lipgloss.Style
|
|
141
|
+
modalTitle lipgloss.Style
|
|
142
|
+
modalKey lipgloss.Style
|
|
143
|
+
modalSearch lipgloss.Style
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
var dracula = theme{
|
|
147
|
+
header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#E2E8F0")),
|
|
148
|
+
accent: lipgloss.NewStyle().Foreground(lipgloss.Color("#7DD3FC")),
|
|
149
|
+
muted: lipgloss.NewStyle().Foreground(lipgloss.Color("#64748B")),
|
|
150
|
+
success: lipgloss.NewStyle().Foreground(lipgloss.Color("#34D399")),
|
|
151
|
+
warning: lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")),
|
|
152
|
+
error: lipgloss.NewStyle().Foreground(lipgloss.Color("#F87171")),
|
|
153
|
+
panel: lipgloss.NewStyle().Padding(0, 1),
|
|
154
|
+
command: lipgloss.NewStyle().Foreground(lipgloss.Color("#E2E8F0")),
|
|
155
|
+
timeline: lipgloss.NewStyle().Foreground(lipgloss.Color("#93C5FD")),
|
|
156
|
+
statusRun: lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")).Bold(true),
|
|
157
|
+
statusIdle: lipgloss.NewStyle().Foreground(lipgloss.Color("#34D399")).Bold(true),
|
|
158
|
+
chip: lipgloss.NewStyle().Foreground(lipgloss.Color("#94A3B8")).Background(lipgloss.Color("#0F172A")).Padding(0, 1),
|
|
159
|
+
chipMuted: lipgloss.NewStyle().Foreground(lipgloss.Color("#475569")).Background(lipgloss.Color("#0B1220")).Padding(0, 1),
|
|
160
|
+
composerTag: lipgloss.NewStyle().Foreground(lipgloss.Color("#38BDF8")).Bold(true),
|
|
161
|
+
userCard: lipgloss.NewStyle().
|
|
162
|
+
Foreground(lipgloss.Color("#E2E8F0")).
|
|
163
|
+
Padding(0, 0, 0, 1).
|
|
164
|
+
MarginBottom(1),
|
|
165
|
+
assistant: lipgloss.NewStyle().
|
|
166
|
+
Foreground(lipgloss.Color("#94A3B8")).
|
|
167
|
+
Padding(0, 0, 0, 1).
|
|
168
|
+
MarginBottom(1),
|
|
169
|
+
noteCard: lipgloss.NewStyle().
|
|
170
|
+
Foreground(lipgloss.Color("#CBD5E1")).
|
|
171
|
+
Padding(0, 0, 0, 1).
|
|
172
|
+
MarginBottom(1),
|
|
173
|
+
errorCard: lipgloss.NewStyle().
|
|
174
|
+
Foreground(lipgloss.Color("#FECACA")).
|
|
175
|
+
Padding(0, 0, 0, 1).
|
|
176
|
+
MarginBottom(1),
|
|
177
|
+
footer: lipgloss.NewStyle().Foreground(lipgloss.Color("#475569")),
|
|
178
|
+
menuBox: lipgloss.NewStyle().
|
|
179
|
+
Background(lipgloss.Color("#1E1E2E")).
|
|
180
|
+
Padding(0, 1).
|
|
181
|
+
MarginBottom(0),
|
|
182
|
+
menuItem: lipgloss.NewStyle().
|
|
183
|
+
Foreground(lipgloss.Color("#F8FAFC")).
|
|
184
|
+
Bold(true),
|
|
185
|
+
menuSelected: lipgloss.NewStyle().
|
|
186
|
+
Background(lipgloss.Color("#F97316")).
|
|
187
|
+
Foreground(lipgloss.Color("#FFFFFF")).
|
|
188
|
+
Bold(true),
|
|
189
|
+
menuDesc: lipgloss.NewStyle().
|
|
190
|
+
Foreground(lipgloss.Color("#94A3B8")),
|
|
191
|
+
modalBox: lipgloss.NewStyle().
|
|
192
|
+
Background(lipgloss.Color("#111827")).
|
|
193
|
+
Border(lipgloss.RoundedBorder()).
|
|
194
|
+
BorderForeground(lipgloss.Color("#374151")).
|
|
195
|
+
Padding(1, 2),
|
|
196
|
+
modalTitle: lipgloss.NewStyle().
|
|
197
|
+
Foreground(lipgloss.Color("#F8FAFC")).
|
|
198
|
+
Bold(true),
|
|
199
|
+
modalKey: lipgloss.NewStyle().
|
|
200
|
+
Foreground(lipgloss.Color("#64748B")).
|
|
201
|
+
Italic(true),
|
|
202
|
+
modalSearch: lipgloss.NewStyle().
|
|
203
|
+
Foreground(lipgloss.Color("#F97316")),
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
type commandResultMsg struct {
|
|
207
|
+
command string
|
|
208
|
+
output string
|
|
209
|
+
err error
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
type runExecutionMsg struct {
|
|
213
|
+
command string
|
|
214
|
+
result *runExecutionResult
|
|
215
|
+
err error
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
type chatExecutionResult struct {
|
|
219
|
+
Text string
|
|
220
|
+
Warning string
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
type chatExecutionMsg struct {
|
|
224
|
+
displayPrompt string
|
|
225
|
+
inputNote string
|
|
226
|
+
result *chatExecutionResult
|
|
227
|
+
err error
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
func startInteractiveShell(resumeID string) error {
|
|
231
|
+
m := newInteractiveModel(resumeID)
|
|
232
|
+
p := tea.NewProgram(m, tea.WithAltScreen())
|
|
233
|
+
_, err := p.Run()
|
|
234
|
+
return err
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
func newInteractiveModel(resumeID string) *interactiveModel {
|
|
238
|
+
input := textarea.New()
|
|
239
|
+
input.Placeholder = "Ask Orch anything..."
|
|
240
|
+
input.Prompt = ""
|
|
241
|
+
|
|
242
|
+
input.FocusedStyle.CursorLine = lipgloss.NewStyle()
|
|
243
|
+
input.FocusedStyle.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("#64748B"))
|
|
244
|
+
input.FocusedStyle.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("#F8FAFC"))
|
|
245
|
+
input.FocusedStyle.Prompt = lipgloss.NewStyle()
|
|
246
|
+
input.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#E2E8F0"))
|
|
247
|
+
input.CharLimit = 0
|
|
248
|
+
input.ShowLineNumbers = false
|
|
249
|
+
input.SetHeight(2)
|
|
250
|
+
input.KeyMap.InsertNewline = key.NewBinding(key.WithKeys("ctrl+j"), key.WithHelp("ctrl+j", "newline"))
|
|
251
|
+
input.Focus()
|
|
252
|
+
|
|
253
|
+
sp := spinner.New()
|
|
254
|
+
sp.Spinner = spinner.Line
|
|
255
|
+
|
|
256
|
+
vp := viewport.New(80, 20)
|
|
257
|
+
|
|
258
|
+
activeSession := strings.TrimSpace(resumeID)
|
|
259
|
+
resumed := activeSession != ""
|
|
260
|
+
if activeSession == "" {
|
|
261
|
+
activeSession = generateSessionID()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
cwd, _ := getWorkingDirectory()
|
|
265
|
+
|
|
266
|
+
// Initialize with empty logs.
|
|
267
|
+
lines := []string{}
|
|
268
|
+
vp.SetContent("")
|
|
269
|
+
|
|
270
|
+
providerLine, authLine, modelsLine := readRuntimeStatus()
|
|
271
|
+
|
|
272
|
+
return &interactiveModel{
|
|
273
|
+
viewport: vp,
|
|
274
|
+
input: input,
|
|
275
|
+
spinner: sp,
|
|
276
|
+
logs: lines,
|
|
277
|
+
providerLine: providerLine,
|
|
278
|
+
authLine: authLine,
|
|
279
|
+
modelsLine: modelsLine,
|
|
280
|
+
verboseMode: false,
|
|
281
|
+
sessionID: activeSession,
|
|
282
|
+
resumed: resumed,
|
|
283
|
+
cwd: cwd,
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
func (m *interactiveModel) Init() tea.Cmd {
|
|
288
|
+
return tea.Batch(textarea.Blink)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
func (m *interactiveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
292
|
+
var cmds []tea.Cmd
|
|
293
|
+
|
|
294
|
+
switch msg := msg.(type) {
|
|
295
|
+
case tea.WindowSizeMsg:
|
|
296
|
+
m.width = msg.Width
|
|
297
|
+
m.height = msg.Height
|
|
298
|
+
headerHeight := 2
|
|
299
|
+
inputHeight := 5
|
|
300
|
+
footerHeight := 3
|
|
301
|
+
m.viewport.Width = msg.Width - 4
|
|
302
|
+
m.viewport.Height = max(5, msg.Height-headerHeight-inputHeight-footerHeight)
|
|
303
|
+
|
|
304
|
+
contentWidth := max(40, min(80, m.viewport.Width))
|
|
305
|
+
m.input.SetWidth(contentWidth)
|
|
306
|
+
m.input.SetHeight(max(2, inputHeight-2))
|
|
307
|
+
m.viewport.SetContent(strings.Join(m.logs, "\n"))
|
|
308
|
+
m.viewport.GotoBottom()
|
|
309
|
+
return m, nil
|
|
310
|
+
|
|
311
|
+
case spinner.TickMsg:
|
|
312
|
+
if m.running {
|
|
313
|
+
var cmd tea.Cmd
|
|
314
|
+
m.spinner, cmd = m.spinner.Update(msg)
|
|
315
|
+
return m, cmd
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
case commandResultMsg:
|
|
319
|
+
m.running = false
|
|
320
|
+
m.providerLine, m.authLine, m.modelsLine = readRuntimeStatus()
|
|
321
|
+
m.appendUserMessage(msg.command)
|
|
322
|
+
if strings.TrimSpace(msg.output) != "" {
|
|
323
|
+
m.appendAssistantMessage("Orch", strings.Split(strings.TrimRight(msg.output, "\n"), "\n"))
|
|
324
|
+
}
|
|
325
|
+
if msg.err != nil {
|
|
326
|
+
m.appendErrorMessage(fmt.Sprintf("error: %v", msg.err))
|
|
327
|
+
}
|
|
328
|
+
m.appendSpacer()
|
|
329
|
+
m.viewport.GotoBottom()
|
|
330
|
+
return m, nil
|
|
331
|
+
|
|
332
|
+
case runExecutionMsg:
|
|
333
|
+
m.running = false
|
|
334
|
+
m.providerLine, m.authLine, m.modelsLine = readRuntimeStatus()
|
|
335
|
+
m.appendUserMessage(msg.command)
|
|
336
|
+
if msg.err != nil {
|
|
337
|
+
m.appendErrorMessage(fmt.Sprintf("error: %v", msg.err))
|
|
338
|
+
m.appendSpacer()
|
|
339
|
+
m.viewport.GotoBottom()
|
|
340
|
+
return m, nil
|
|
341
|
+
}
|
|
342
|
+
m.appendAssistantMessage("Orch", []string{naturalRunReply(msg.result)})
|
|
343
|
+
m.appendAssistantMessage("Run Result", compactRunLines(msg.result, m.verboseMode))
|
|
344
|
+
m.appendSpacer()
|
|
345
|
+
m.viewport.GotoBottom()
|
|
346
|
+
return m, nil
|
|
347
|
+
|
|
348
|
+
case chatExecutionMsg:
|
|
349
|
+
m.running = false
|
|
350
|
+
m.providerLine, m.authLine, m.modelsLine = readRuntimeStatus()
|
|
351
|
+
m.appendUserMessage(msg.displayPrompt)
|
|
352
|
+
if strings.TrimSpace(msg.inputNote) != "" {
|
|
353
|
+
m.appendNoteMessage("Input Transform", []string{msg.inputNote})
|
|
354
|
+
}
|
|
355
|
+
if msg.err != nil {
|
|
356
|
+
m.appendErrorMessage(fmt.Sprintf("error: %v", msg.err))
|
|
357
|
+
m.appendSpacer()
|
|
358
|
+
m.viewport.GotoBottom()
|
|
359
|
+
return m, nil
|
|
360
|
+
}
|
|
361
|
+
if msg.result != nil {
|
|
362
|
+
m.appendAssistantMessage("Orch", strings.Split(strings.TrimSpace(msg.result.Text), "\n"))
|
|
363
|
+
if strings.TrimSpace(msg.result.Warning) != "" {
|
|
364
|
+
m.appendNoteMessage("Note", []string{msg.result.Warning})
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
m.appendSpacer()
|
|
368
|
+
m.viewport.GotoBottom()
|
|
369
|
+
return m, nil
|
|
370
|
+
|
|
371
|
+
case tea.KeyMsg:
|
|
372
|
+
switch msg.String() {
|
|
373
|
+
case "ctrl+c":
|
|
374
|
+
return m, tea.Quit
|
|
375
|
+
case "esc":
|
|
376
|
+
if m.activeModal != nil {
|
|
377
|
+
m.activeModal = nil
|
|
378
|
+
return m, nil
|
|
379
|
+
}
|
|
380
|
+
if m.showSuggestions {
|
|
381
|
+
m.showSuggestions = false
|
|
382
|
+
return m, nil
|
|
383
|
+
}
|
|
384
|
+
if strings.TrimSpace(m.input.Value()) != "" {
|
|
385
|
+
m.input.SetValue("")
|
|
386
|
+
}
|
|
387
|
+
return m, nil
|
|
388
|
+
case "up":
|
|
389
|
+
if m.activeModal != nil {
|
|
390
|
+
m.activeModal.Index = (m.activeModal.Index - 1 + len(m.activeModal.Choices)) % len(m.activeModal.Choices)
|
|
391
|
+
return m, nil
|
|
392
|
+
}
|
|
393
|
+
if m.showSuggestions {
|
|
394
|
+
m.suggestionIdx = (m.suggestionIdx - 1 + len(m.suggestions)) % len(m.suggestions)
|
|
395
|
+
return m, nil
|
|
396
|
+
}
|
|
397
|
+
case "down":
|
|
398
|
+
if m.activeModal != nil {
|
|
399
|
+
m.activeModal.Index = (m.activeModal.Index + 1) % len(m.activeModal.Choices)
|
|
400
|
+
return m, nil
|
|
401
|
+
}
|
|
402
|
+
if m.showSuggestions {
|
|
403
|
+
m.suggestionIdx = (m.suggestionIdx + 1) % len(m.suggestions)
|
|
404
|
+
return m, nil
|
|
405
|
+
}
|
|
406
|
+
case "tab":
|
|
407
|
+
if m.showSuggestions && len(m.suggestions) > 0 {
|
|
408
|
+
m.input.SetValue(m.suggestions[m.suggestionIdx].Name + " ")
|
|
409
|
+
m.input.SetCursor(len(m.input.Value()))
|
|
410
|
+
m.showSuggestions = false
|
|
411
|
+
return m, nil
|
|
412
|
+
}
|
|
413
|
+
case "ctrl+l":
|
|
414
|
+
m.logs = []string{}
|
|
415
|
+
m.viewport.SetContent("")
|
|
416
|
+
return m, nil
|
|
417
|
+
case "shift+enter", "alt+enter":
|
|
418
|
+
if m.running {
|
|
419
|
+
return m, nil
|
|
420
|
+
}
|
|
421
|
+
m.input.InsertString("\n")
|
|
422
|
+
return m, nil
|
|
423
|
+
case "enter", "ctrl+m":
|
|
424
|
+
if m.activeModal != nil {
|
|
425
|
+
active := m.activeModal
|
|
426
|
+
choice := active.Choices[active.Index]
|
|
427
|
+
|
|
428
|
+
if active.Type == modalProvider {
|
|
429
|
+
// Move to auth step
|
|
430
|
+
methods, ok := authMethods[choice.ID]
|
|
431
|
+
if !ok {
|
|
432
|
+
// Simple selection if no methods defined (future proofing)
|
|
433
|
+
m.activeModal = nil
|
|
434
|
+
m.input.SetValue("/provider " + choice.ID)
|
|
435
|
+
return m.handleCommand()
|
|
436
|
+
}
|
|
437
|
+
m.activeModal = &modalState{
|
|
438
|
+
Type: modalAuth,
|
|
439
|
+
Title: "Select auth method",
|
|
440
|
+
Choices: methods,
|
|
441
|
+
Selected: choice.ID,
|
|
442
|
+
}
|
|
443
|
+
return m, nil
|
|
444
|
+
} else if active.Type == modalAuth {
|
|
445
|
+
// Final selection
|
|
446
|
+
provider := active.Selected
|
|
447
|
+
method := choice.ID
|
|
448
|
+
m.activeModal = nil
|
|
449
|
+
|
|
450
|
+
if method == "browser" || method == "headless" {
|
|
451
|
+
m.input.SetValue(fmt.Sprintf("/auth login --provider %s --method account --flow %s", provider, method))
|
|
452
|
+
} else if method == "api_key" {
|
|
453
|
+
m.input.SetValue(fmt.Sprintf("/auth login --provider %s --method api", provider))
|
|
454
|
+
} else {
|
|
455
|
+
m.input.SetValue(fmt.Sprintf("/auth login --provider %s --method %s", provider, method))
|
|
456
|
+
}
|
|
457
|
+
return m.handleCommand()
|
|
458
|
+
}
|
|
459
|
+
return m, nil
|
|
460
|
+
}
|
|
461
|
+
if m.showSuggestions && len(m.suggestions) > 0 {
|
|
462
|
+
m.input.SetValue(m.suggestions[m.suggestionIdx].Name + " ")
|
|
463
|
+
m.input.SetCursor(len(m.input.Value()))
|
|
464
|
+
m.showSuggestions = false
|
|
465
|
+
return m, nil
|
|
466
|
+
}
|
|
467
|
+
if m.running {
|
|
468
|
+
return m, nil
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
raw := strings.TrimSpace(m.input.Value())
|
|
472
|
+
if raw == "" {
|
|
473
|
+
return m, nil
|
|
474
|
+
}
|
|
475
|
+
// handleCommand will clear the input
|
|
476
|
+
return m.handleCommand()
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
var cmd tea.Cmd
|
|
481
|
+
m.input, cmd = m.input.Update(msg)
|
|
482
|
+
cmds = append(cmds, cmd)
|
|
483
|
+
|
|
484
|
+
// Update suggestions
|
|
485
|
+
val := m.input.Value()
|
|
486
|
+
if strings.HasPrefix(val, "/") && !strings.Contains(val, " ") {
|
|
487
|
+
m.suggestions = nil
|
|
488
|
+
for _, c := range allCommands {
|
|
489
|
+
if strings.HasPrefix(c.Name, val) {
|
|
490
|
+
m.suggestions = append(m.suggestions, c)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if len(m.suggestions) > 0 {
|
|
494
|
+
m.showSuggestions = true
|
|
495
|
+
if m.suggestionIdx >= len(m.suggestions) {
|
|
496
|
+
m.suggestionIdx = 0
|
|
497
|
+
}
|
|
498
|
+
} else {
|
|
499
|
+
m.showSuggestions = false
|
|
500
|
+
}
|
|
501
|
+
} else {
|
|
502
|
+
m.showSuggestions = false
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
m.viewport, cmd = m.viewport.Update(msg)
|
|
506
|
+
cmds = append(cmds, cmd)
|
|
507
|
+
|
|
508
|
+
return m, tea.Batch(cmds...)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
func (m *interactiveModel) handleCommand() (tea.Model, tea.Cmd) {
|
|
512
|
+
raw := strings.TrimSpace(m.input.Value())
|
|
513
|
+
m.input.SetValue("")
|
|
514
|
+
|
|
515
|
+
if raw == "/exit" || raw == "/quit" {
|
|
516
|
+
return m, tea.Quit
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if raw == "/clear" {
|
|
520
|
+
m.logs = []string{}
|
|
521
|
+
m.viewport.SetContent("")
|
|
522
|
+
return m, nil
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if raw == "/help" {
|
|
526
|
+
m.appendAssistantMessage("Commands", strings.Split(helpText(), "\n"))
|
|
527
|
+
m.appendSpacer()
|
|
528
|
+
m.viewport.GotoBottom()
|
|
529
|
+
return m, nil
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if strings.HasPrefix(raw, "/provider") || strings.HasPrefix(raw, "/connect") {
|
|
533
|
+
parts := strings.Fields(raw)
|
|
534
|
+
if len(parts) == 1 {
|
|
535
|
+
// Trigger interactive modal
|
|
536
|
+
m.activeModal = &modalState{
|
|
537
|
+
Type: modalProvider,
|
|
538
|
+
Title: "Connect a provider",
|
|
539
|
+
Choices: providersList,
|
|
540
|
+
Index: 0,
|
|
541
|
+
}
|
|
542
|
+
return m, nil
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if strings.HasPrefix(raw, "/auth") {
|
|
547
|
+
parts := strings.Fields(raw)
|
|
548
|
+
if len(parts) == 1 {
|
|
549
|
+
// If we have an active provider already, we can skip to auth selection
|
|
550
|
+
m.activeModal = &modalState{
|
|
551
|
+
Type: modalProvider,
|
|
552
|
+
Title: "Select a provider to authenticate",
|
|
553
|
+
Choices: providersList,
|
|
554
|
+
Index: 0,
|
|
555
|
+
}
|
|
556
|
+
return m, nil
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if strings.HasPrefix(raw, "/verbose") {
|
|
561
|
+
parts := strings.Fields(raw)
|
|
562
|
+
if len(parts) == 1 {
|
|
563
|
+
m.verboseMode = !m.verboseMode
|
|
564
|
+
} else {
|
|
565
|
+
switch strings.ToLower(parts[1]) {
|
|
566
|
+
case "on":
|
|
567
|
+
m.verboseMode = true
|
|
568
|
+
case "off":
|
|
569
|
+
m.verboseMode = false
|
|
570
|
+
default:
|
|
571
|
+
m.appendErrorMessage("error: /verbose expects 'on' or 'off'")
|
|
572
|
+
m.appendSpacer()
|
|
573
|
+
m.viewport.GotoBottom()
|
|
574
|
+
return m, nil
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
m.appendAssistantMessage("Settings", []string{fmt.Sprintf("verbose mode: %t", m.verboseMode)})
|
|
578
|
+
m.appendSpacer()
|
|
579
|
+
m.viewport.GotoBottom()
|
|
580
|
+
return m, nil
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
dispatch, err := prepareInteractiveDispatch(raw)
|
|
584
|
+
if err != nil {
|
|
585
|
+
m.appendErrorMessage(fmt.Sprintf("error: %v", err))
|
|
586
|
+
m.appendSpacer()
|
|
587
|
+
m.viewport.GotoBottom()
|
|
588
|
+
return m, nil
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// If transitioning from empty state to active state, resize the input to correct content width
|
|
592
|
+
if len(m.logs) == 0 {
|
|
593
|
+
contentWidth := max(40, min(80, m.viewport.Width))
|
|
594
|
+
m.input.SetWidth(contentWidth)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
var cmds []tea.Cmd
|
|
598
|
+
m.running = true
|
|
599
|
+
if len(dispatch.Args) > 1 && dispatch.Args[0] == "run" {
|
|
600
|
+
cmds = append(cmds, m.spinner.Tick, runInProcessCmd(dispatch.Args[1]))
|
|
601
|
+
} else if len(dispatch.Args) > 1 && dispatch.Args[0] == "chat" {
|
|
602
|
+
cmds = append(cmds, m.spinner.Tick, runInProcessChatCmd(dispatch.DisplayInput, dispatch.Args[1], dispatch.InputNote))
|
|
603
|
+
} else {
|
|
604
|
+
cmds = append(cmds, m.spinner.Tick, runCLICommandCmd(dispatch.Args))
|
|
605
|
+
}
|
|
606
|
+
return m, tea.Batch(cmds...)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
func (m *interactiveModel) View() string {
|
|
610
|
+
shellWidth := max(40, m.width)
|
|
611
|
+
shellHeight := max(10, m.height)
|
|
612
|
+
|
|
613
|
+
var bg string
|
|
614
|
+
if len(m.logs) == 0 {
|
|
615
|
+
bg = m.renderEmptyState(shellWidth, shellHeight)
|
|
616
|
+
} else {
|
|
617
|
+
providerState := "unknown"
|
|
618
|
+
if !strings.Contains(strings.ToLower(m.providerLine), "inactive") && !strings.Contains(strings.ToLower(m.providerLine), "unknown") {
|
|
619
|
+
providerState = "provider configured"
|
|
620
|
+
}
|
|
621
|
+
authState := "disconnected"
|
|
622
|
+
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(m.authLine)), "auth: connected") {
|
|
623
|
+
authState = "auth configured"
|
|
624
|
+
}
|
|
625
|
+
modelSummary := shortModelsLine(m.modelsLine)
|
|
626
|
+
|
|
627
|
+
contentWidth := max(60, min(80, m.viewport.Width))
|
|
628
|
+
|
|
629
|
+
headerInfo := dracula.muted.Render(fmt.Sprintf("%s • %s • %s", providerState, authState, modelSummary))
|
|
630
|
+
header := lipgloss.PlaceHorizontal(m.viewport.Width, lipgloss.Right, headerInfo) + "\n"
|
|
631
|
+
|
|
632
|
+
bodyContent := dracula.panel.Width(contentWidth).Render(m.viewport.View())
|
|
633
|
+
body := lipgloss.PlaceHorizontal(m.viewport.Width, lipgloss.Center, bodyContent)
|
|
634
|
+
|
|
635
|
+
composerContent := dracula.panel.Width(contentWidth).Render(m.input.View())
|
|
636
|
+
|
|
637
|
+
var suggestionsView string
|
|
638
|
+
if m.showSuggestions {
|
|
639
|
+
var lines []string
|
|
640
|
+
for i, s := range m.suggestions {
|
|
641
|
+
nameStyle := dracula.menuItem
|
|
642
|
+
if i == m.suggestionIdx {
|
|
643
|
+
nameStyle = dracula.menuSelected
|
|
644
|
+
}
|
|
645
|
+
line := nameStyle.Render(fmt.Sprintf(" %-12s ", s.Name)) + " " + dracula.menuDesc.Render(s.Desc)
|
|
646
|
+
lines = append(lines, line)
|
|
647
|
+
}
|
|
648
|
+
suggestionsContent := dracula.menuBox.Width(contentWidth - 2).Render(strings.Join(lines, "\n"))
|
|
649
|
+
suggestionsView = lipgloss.PlaceHorizontal(m.viewport.Width, lipgloss.Center, dracula.panel.Width(contentWidth).Render(suggestionsContent)) + "\n"
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
composer := lipgloss.PlaceHorizontal(m.viewport.Width, lipgloss.Center, composerContent)
|
|
653
|
+
bg = header + body + "\n" + suggestionsView + composer + "\n"
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if m.activeModal != nil {
|
|
657
|
+
modal := m.renderModal(shellWidth, shellHeight)
|
|
658
|
+
// Instead of clearing the background, we can return the modal separately.
|
|
659
|
+
// However, Bubble Tea's View() returns the single final string.
|
|
660
|
+
// To truly "overlay", we should join the background with the modal.
|
|
661
|
+
// But centered modals usually replace the view or use a layered approach.
|
|
662
|
+
// For verification, returning just the modal centered should be visible.
|
|
663
|
+
return lipgloss.Place(shellWidth, shellHeight, lipgloss.Center, lipgloss.Center, modal)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return bg
|
|
667
|
+
}
|
|
668
|
+
func (m *interactiveModel) renderEmptyState(width, height int) string {
|
|
669
|
+
logo := `
|
|
670
|
+
██████╗ ██████╗ ██████╗██╗ ██╗
|
|
671
|
+
██╔═══██╗██╔══██╗██╔════╝██║ ██║
|
|
672
|
+
██║ ██║██████╔╝██║ ███████║
|
|
673
|
+
██║ ██║██╔══██╗██║ ██╔══██║
|
|
674
|
+
╚██████╔╝██║ ██║╚██████╗██║ ██║
|
|
675
|
+
╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝`
|
|
676
|
+
logoStr := lipgloss.NewStyle().
|
|
677
|
+
Bold(true).
|
|
678
|
+
Foreground(lipgloss.Color("#94A3B8")).
|
|
679
|
+
Render(logo)
|
|
680
|
+
|
|
681
|
+
modelSummary := shortModelsLine(m.modelsLine)
|
|
682
|
+
providerState := "unknown"
|
|
683
|
+
if !strings.Contains(strings.ToLower(m.providerLine), "inactive") && !strings.Contains(strings.ToLower(m.providerLine), "unknown") {
|
|
684
|
+
providerState = "provider configured"
|
|
685
|
+
}
|
|
686
|
+
authState := "disconnected"
|
|
687
|
+
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(m.authLine)), "auth: connected") {
|
|
688
|
+
authState = "auth configured"
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Status line underneath the input
|
|
692
|
+
statusStr := dracula.muted.Render(fmt.Sprintf("%s • %s", providerState, authState))
|
|
693
|
+
statsStr := dracula.muted.Render(modelSummary)
|
|
694
|
+
|
|
695
|
+
contentWidth := max(40, min(80, width))
|
|
696
|
+
|
|
697
|
+
// The composer wrapping
|
|
698
|
+
inputBox := dracula.panel.Width(contentWidth).Render(m.input.View())
|
|
699
|
+
|
|
700
|
+
// Suggestions overlay
|
|
701
|
+
var suggestionsView string
|
|
702
|
+
if m.showSuggestions {
|
|
703
|
+
var lines []string
|
|
704
|
+
for i, s := range m.suggestions {
|
|
705
|
+
name := s.Name
|
|
706
|
+
desc := s.Desc
|
|
707
|
+
nameStyle := dracula.menuItem
|
|
708
|
+
if i == m.suggestionIdx {
|
|
709
|
+
nameStyle = dracula.menuSelected
|
|
710
|
+
}
|
|
711
|
+
line := nameStyle.Render(fmt.Sprintf(" %-12s ", name)) + " " + dracula.menuDesc.Render(desc)
|
|
712
|
+
lines = append(lines, line)
|
|
713
|
+
}
|
|
714
|
+
suggestionsContent := dracula.menuBox.Width(contentWidth - 2).Render(strings.Join(lines, "\n"))
|
|
715
|
+
suggestionsView = dracula.panel.Width(contentWidth).Render(suggestionsContent)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
helpLine := dracula.warning.Render("• Tip") + dracula.muted.Render(" Use /help for commands. Plain text for chat. /run for tasks.")
|
|
719
|
+
|
|
720
|
+
// Assemble the center block
|
|
721
|
+
centerItems := []string{
|
|
722
|
+
logoStr,
|
|
723
|
+
"\n",
|
|
724
|
+
}
|
|
725
|
+
if suggestionsView != "" {
|
|
726
|
+
centerItems = append(centerItems, suggestionsView)
|
|
727
|
+
}
|
|
728
|
+
centerItems = append(centerItems,
|
|
729
|
+
lipgloss.PlaceHorizontal(contentWidth, lipgloss.Center, inputBox),
|
|
730
|
+
"\n",
|
|
731
|
+
lipgloss.PlaceHorizontal(contentWidth, lipgloss.Center, statusStr+" | "+statsStr),
|
|
732
|
+
"\n\n\n",
|
|
733
|
+
lipgloss.PlaceHorizontal(contentWidth, lipgloss.Center, helpLine),
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
centerBlock := lipgloss.JoinVertical(lipgloss.Center, centerItems...)
|
|
737
|
+
|
|
738
|
+
if m.activeModal != nil {
|
|
739
|
+
modal := m.renderModal(width, height)
|
|
740
|
+
// We can use lipgloss.Place to put the modal on top of the empty state
|
|
741
|
+
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, modal)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, centerBlock)
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
func (m *interactiveModel) renderModal(width, height int) string {
|
|
748
|
+
modal := m.activeModal
|
|
749
|
+
if modal == nil {
|
|
750
|
+
return ""
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
modalWidth := max(40, min(60, width-10))
|
|
754
|
+
|
|
755
|
+
title := dracula.modalTitle.Render(modal.Title)
|
|
756
|
+
escLabel := dracula.modalKey.Render("esc")
|
|
757
|
+
|
|
758
|
+
header := lipgloss.JoinHorizontal(lipgloss.Left,
|
|
759
|
+
title,
|
|
760
|
+
strings.Repeat(" ", max(0, modalWidth-lipgloss.Width(title)-lipgloss.Width(escLabel))),
|
|
761
|
+
escLabel)
|
|
762
|
+
|
|
763
|
+
search := "\n" + dracula.modalSearch.Render("S") + dracula.muted.Render("earch") + "\n"
|
|
764
|
+
|
|
765
|
+
var lines []string
|
|
766
|
+
lines = append(lines, header, search)
|
|
767
|
+
|
|
768
|
+
for i, choice := range modal.Choices {
|
|
769
|
+
text := choice.Text
|
|
770
|
+
sub := choice.Sub
|
|
771
|
+
|
|
772
|
+
var line string
|
|
773
|
+
if i == modal.Index {
|
|
774
|
+
content := text
|
|
775
|
+
if sub != "" {
|
|
776
|
+
content += " " + dracula.muted.Render(sub)
|
|
777
|
+
}
|
|
778
|
+
line = dracula.menuSelected.Width(modalWidth).Render(content)
|
|
779
|
+
} else {
|
|
780
|
+
content := text
|
|
781
|
+
if sub != "" {
|
|
782
|
+
content += " " + dracula.muted.Render(sub)
|
|
783
|
+
}
|
|
784
|
+
line = dracula.menuItem.Render(content)
|
|
785
|
+
}
|
|
786
|
+
lines = append(lines, line)
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return dracula.modalBox.Width(modalWidth).Render(strings.Join(lines, "\n"))
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
func (m *interactiveModel) appendLog(line string) {
|
|
793
|
+
m.logs = append(m.logs, line)
|
|
794
|
+
m.viewport.SetContent(strings.Join(m.logs, "\n"))
|
|
795
|
+
m.viewport.GotoBottom()
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
func (m *interactiveModel) appendSpacer() {
|
|
799
|
+
m.appendLog("")
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
func (m *interactiveModel) appendUserMessage(command string) {
|
|
803
|
+
// A simple cyan dot indicator for user messages
|
|
804
|
+
indicator := dracula.accent.Render("● ")
|
|
805
|
+
body := indicator + dracula.header.Render("You") + "\n" + dracula.command.Render(command)
|
|
806
|
+
card := dracula.userCard.Width(m.cardWidth()).Render(body)
|
|
807
|
+
m.appendLog(card)
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
func (m *interactiveModel) appendAssistantMessage(title string, lines []string) {
|
|
811
|
+
indicator := dracula.muted.Render("○ ")
|
|
812
|
+
|
|
813
|
+
header := ""
|
|
814
|
+
if title == "Orch" || title == "Output" || title == "Commands" {
|
|
815
|
+
header = dracula.header.Render("Orch Output") + "\n"
|
|
816
|
+
} else {
|
|
817
|
+
header = dracula.header.Render(title) + "\n"
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
m.appendLog(dracula.assistant.Width(m.cardWidth()).Render(indicator + header + strings.Join(lines, "\n")))
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
func (m *interactiveModel) appendNoteMessage(title string, lines []string) {
|
|
824
|
+
body := make([]string, 0, len(lines)+1)
|
|
825
|
+
body = append(body, dracula.warning.Render("Note")+" "+dracula.header.Render(title))
|
|
826
|
+
body = append(body, lines...)
|
|
827
|
+
m.appendLog(dracula.noteCard.Width(m.cardWidth()).Render(strings.Join(body, "\n")))
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
func (m *interactiveModel) appendErrorMessage(message string) {
|
|
831
|
+
body := dracula.error.Render("Error") + "\n" + message
|
|
832
|
+
m.appendLog(dracula.errorCard.Width(m.cardWidth()).Render(body))
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
func (m interactiveModel) cardWidth() int {
|
|
836
|
+
return m.viewport.Width - 4
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
func parseInteractiveInput(input string) ([]string, error) {
|
|
840
|
+
if strings.HasPrefix(input, "/") {
|
|
841
|
+
parts := strings.Fields(strings.TrimPrefix(input, "/"))
|
|
842
|
+
if len(parts) == 0 {
|
|
843
|
+
return nil, fmt.Errorf("empty command")
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
switch parts[0] {
|
|
847
|
+
case "run", "plan", "chat":
|
|
848
|
+
if len(parts) < 2 {
|
|
849
|
+
return nil, fmt.Errorf("/%s requires a task", parts[0])
|
|
850
|
+
}
|
|
851
|
+
return []string{parts[0], strings.Join(parts[1:], " ")}, nil
|
|
852
|
+
case "diff", "apply", "init":
|
|
853
|
+
return []string{parts[0]}, nil
|
|
854
|
+
case "doctor", "provider", "model", "models", "auth", "connect":
|
|
855
|
+
if parts[0] == "models" {
|
|
856
|
+
parts[0] = "model"
|
|
857
|
+
}
|
|
858
|
+
if parts[0] == "connect" {
|
|
859
|
+
parts[0] = "auth"
|
|
860
|
+
parts = append([]string{"auth", "login"}, parts[1:]...)
|
|
861
|
+
return parts, nil
|
|
862
|
+
}
|
|
863
|
+
return append([]string{parts[0]}, parts[1:]...), nil
|
|
864
|
+
case "logs":
|
|
865
|
+
return append([]string{"logs"}, parts[1:]...), nil
|
|
866
|
+
case "explain":
|
|
867
|
+
return append([]string{"explain"}, parts[1:]...), nil
|
|
868
|
+
case "stats":
|
|
869
|
+
return append([]string{"stats"}, parts[1:]...), nil
|
|
870
|
+
case "session":
|
|
871
|
+
return append([]string{"session"}, parts[1:]...), nil
|
|
872
|
+
default:
|
|
873
|
+
return nil, fmt.Errorf("unknown command: %s", parts[0])
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
return []string{"chat", input}, nil
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
func runCLICommandCmd(args []string) tea.Cmd {
|
|
881
|
+
commandLabel := "orch " + strings.Join(args, " ")
|
|
882
|
+
return func() tea.Msg {
|
|
883
|
+
cmd := exec.Command(os.Args[0], args...)
|
|
884
|
+
output, err := cmd.CombinedOutput()
|
|
885
|
+
return commandResultMsg{
|
|
886
|
+
command: commandLabel,
|
|
887
|
+
output: string(output),
|
|
888
|
+
err: err,
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
func runInProcessCmd(task string) tea.Cmd {
|
|
894
|
+
commandLabel := "orch run " + task
|
|
895
|
+
return func() tea.Msg {
|
|
896
|
+
result, err := executeRunTask(task)
|
|
897
|
+
return runExecutionMsg{
|
|
898
|
+
command: commandLabel,
|
|
899
|
+
result: result,
|
|
900
|
+
err: err,
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
func runInProcessChatCmd(displayPrompt, prompt, inputNote string) tea.Cmd {
|
|
906
|
+
return func() tea.Msg {
|
|
907
|
+
result, err := executeChatPrompt(prompt)
|
|
908
|
+
return chatExecutionMsg{
|
|
909
|
+
displayPrompt: displayPrompt,
|
|
910
|
+
inputNote: inputNote,
|
|
911
|
+
result: result,
|
|
912
|
+
err: err,
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
func executeChatPrompt(prompt string) (*chatExecutionResult, error) {
|
|
918
|
+
cwd, err := getWorkingDirectory()
|
|
919
|
+
if err != nil {
|
|
920
|
+
return nil, err
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
sessionCtx, err := loadSessionContext(cwd)
|
|
924
|
+
if err != nil {
|
|
925
|
+
return nil, fmt.Errorf("session unavailable: %w", err)
|
|
926
|
+
}
|
|
927
|
+
defer sessionCtx.Store.Close()
|
|
928
|
+
svc := session.NewService(sessionCtx.Store)
|
|
929
|
+
compactionNote := ""
|
|
930
|
+
|
|
931
|
+
cfg, err := config.Load(cwd)
|
|
932
|
+
if err != nil {
|
|
933
|
+
return nil, fmt.Errorf("provider unavailable: failed to load config")
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if !cfg.Provider.Flags.OpenAIEnabled || strings.ToLower(strings.TrimSpace(cfg.Provider.Default)) != "openai" {
|
|
937
|
+
return nil, fmt.Errorf("provider unavailable: OpenAI provider is disabled or not selected")
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
status := runtimeStatusSnapshot()
|
|
941
|
+
if !status.providerConfigured {
|
|
942
|
+
return nil, fmt.Errorf("provider unavailable: %s", status.providerLine)
|
|
943
|
+
}
|
|
944
|
+
if !status.authConnected {
|
|
945
|
+
return nil, fmt.Errorf("provider unavailable: %s", status.authLine)
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
if compacted, note, compactErr := svc.MaybeCompact(sessionCtx.Session.ID, cfg.Provider.OpenAI.Models.Coder); compactErr == nil && compacted {
|
|
949
|
+
compactionNote = note
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
userMsg, err := svc.AppendText(session.MessageInput{
|
|
953
|
+
SessionID: sessionCtx.Session.ID,
|
|
954
|
+
Role: "user",
|
|
955
|
+
ProviderID: cfg.Provider.Default,
|
|
956
|
+
ModelID: cfg.Provider.OpenAI.Models.Coder,
|
|
957
|
+
Text: prompt,
|
|
958
|
+
})
|
|
959
|
+
if err != nil {
|
|
960
|
+
return nil, fmt.Errorf("session write failed: %w", err)
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
client := openai.New(cfg.Provider.OpenAI)
|
|
964
|
+
client.SetTokenResolver(func(ctx context.Context) (string, error) {
|
|
965
|
+
_ = ctx
|
|
966
|
+
mode := strings.ToLower(strings.TrimSpace(cfg.Provider.OpenAI.AuthMode))
|
|
967
|
+
if mode == "api_key" {
|
|
968
|
+
cred, credErr := auth.Get(cwd, "openai")
|
|
969
|
+
if credErr != nil || cred == nil {
|
|
970
|
+
return "", credErr
|
|
971
|
+
}
|
|
972
|
+
if strings.ToLower(strings.TrimSpace(cred.Type)) == "api" {
|
|
973
|
+
return strings.TrimSpace(cred.Key), nil
|
|
974
|
+
}
|
|
975
|
+
return "", nil
|
|
976
|
+
}
|
|
977
|
+
return auth.ResolveAccountAccessToken(cwd, "openai")
|
|
978
|
+
})
|
|
979
|
+
|
|
980
|
+
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.Provider.OpenAI.TimeoutSeconds)*time.Second)
|
|
981
|
+
defer cancel()
|
|
982
|
+
|
|
983
|
+
resp, chatErr := client.Chat(ctx, providers.ChatRequest{
|
|
984
|
+
Role: providers.RoleCoder,
|
|
985
|
+
Model: cfg.Provider.OpenAI.Models.Coder,
|
|
986
|
+
SystemPrompt: "You are Orch interactive assistant. Be concise and practical.",
|
|
987
|
+
UserPrompt: prompt,
|
|
988
|
+
})
|
|
989
|
+
if chatErr != nil {
|
|
990
|
+
errorPayload, _ := json.Marshal(map[string]string{"message": chatErr.Error()})
|
|
991
|
+
_, _ = svc.AppendMessage(session.MessageInput{
|
|
992
|
+
SessionID: sessionCtx.Session.ID,
|
|
993
|
+
Role: "assistant",
|
|
994
|
+
ParentID: userMsg.Message.ID,
|
|
995
|
+
ProviderID: cfg.Provider.Default,
|
|
996
|
+
ModelID: cfg.Provider.OpenAI.Models.Coder,
|
|
997
|
+
FinishReason: "error",
|
|
998
|
+
Error: chatErr.Error(),
|
|
999
|
+
}, []storage.SessionPart{{Type: "error", Payload: string(errorPayload)}})
|
|
1000
|
+
return nil, fmt.Errorf("provider chat failed: %w", chatErr)
|
|
1001
|
+
}
|
|
1002
|
+
if strings.TrimSpace(resp.Text) == "" {
|
|
1003
|
+
errorPayload, _ := json.Marshal(map[string]string{"message": "provider returned an empty response"})
|
|
1004
|
+
_, _ = svc.AppendMessage(session.MessageInput{
|
|
1005
|
+
SessionID: sessionCtx.Session.ID,
|
|
1006
|
+
Role: "assistant",
|
|
1007
|
+
ParentID: userMsg.Message.ID,
|
|
1008
|
+
ProviderID: cfg.Provider.Default,
|
|
1009
|
+
ModelID: cfg.Provider.OpenAI.Models.Coder,
|
|
1010
|
+
FinishReason: "error",
|
|
1011
|
+
Error: "provider returned an empty response",
|
|
1012
|
+
}, []storage.SessionPart{{Type: "error", Payload: string(errorPayload)}})
|
|
1013
|
+
return nil, fmt.Errorf("provider returned an empty response")
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
assistantMsg, appendErr := svc.AppendText(session.MessageInput{
|
|
1017
|
+
SessionID: sessionCtx.Session.ID,
|
|
1018
|
+
Role: "assistant",
|
|
1019
|
+
ParentID: userMsg.Message.ID,
|
|
1020
|
+
ProviderID: cfg.Provider.Default,
|
|
1021
|
+
ModelID: cfg.Provider.OpenAI.Models.Coder,
|
|
1022
|
+
FinishReason: "stop",
|
|
1023
|
+
Text: strings.TrimSpace(resp.Text),
|
|
1024
|
+
})
|
|
1025
|
+
warning := ""
|
|
1026
|
+
if appendErr != nil {
|
|
1027
|
+
warning = fmt.Sprintf("session write warning: %v", appendErr)
|
|
1028
|
+
}
|
|
1029
|
+
if strings.TrimSpace(compactionNote) != "" {
|
|
1030
|
+
if warning == "" {
|
|
1031
|
+
warning = compactionNote
|
|
1032
|
+
} else {
|
|
1033
|
+
warning = warning + "; " + compactionNote
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
if appendErr == nil {
|
|
1037
|
+
turnCount := 1
|
|
1038
|
+
if metrics, metricsErr := sessionCtx.Store.GetSessionMetrics(sessionCtx.Session.ID); metricsErr == nil && metrics != nil {
|
|
1039
|
+
turnCount = metrics.TurnCount + 1
|
|
1040
|
+
}
|
|
1041
|
+
_ = sessionCtx.Store.UpsertSessionMetrics(storage.SessionMetrics{
|
|
1042
|
+
SessionID: sessionCtx.Session.ID,
|
|
1043
|
+
TurnCount: turnCount,
|
|
1044
|
+
LastMessageID: assistantMsg.Message.ID,
|
|
1045
|
+
})
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
return &chatExecutionResult{Text: strings.TrimSpace(resp.Text), Warning: warning}, nil
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
func helpText() string {
|
|
1052
|
+
return strings.Join([]string{
|
|
1053
|
+
"Commands:",
|
|
1054
|
+
" /chat <message> Chat with Orch",
|
|
1055
|
+
" /run <task> Run full pipeline",
|
|
1056
|
+
" /plan <task> Generate plan only",
|
|
1057
|
+
" ?quick <message> Local concise chat transform",
|
|
1058
|
+
" /diff Show latest patch",
|
|
1059
|
+
" /apply Apply latest patch (dry-run by default)",
|
|
1060
|
+
" /doctor Validate provider/runtime readiness",
|
|
1061
|
+
" /provider Show provider configuration",
|
|
1062
|
+
" /connect Open provider auth flow",
|
|
1063
|
+
" /provider set openai Set default provider",
|
|
1064
|
+
" /auth status Show authentication status",
|
|
1065
|
+
" /auth login [provider] --method account|api --flow auto|browser|headless",
|
|
1066
|
+
" /auth list List stored credentials",
|
|
1067
|
+
" /auth logout [provider] Remove stored credential",
|
|
1068
|
+
" /model Show role model mapping",
|
|
1069
|
+
" /models Alias for /model",
|
|
1070
|
+
" /model set <role> <model> Set role model",
|
|
1071
|
+
" /logs [run-id] Show logs",
|
|
1072
|
+
" /explain [run-id] Explain a run using structured artifacts",
|
|
1073
|
+
" /stats Show quality stats for recent runs",
|
|
1074
|
+
" /session <subcommand> Session operations",
|
|
1075
|
+
" /init Initialize project",
|
|
1076
|
+
" /verbose [on|off] Toggle detailed run output",
|
|
1077
|
+
" /clear Clear screen output",
|
|
1078
|
+
" /exit Quit",
|
|
1079
|
+
"",
|
|
1080
|
+
"Tip: plain text input starts a chat message.",
|
|
1081
|
+
"Tip: use /run when you want code generation workflow.",
|
|
1082
|
+
"Tip: use ?quick when you want a concise local prompt transform without another LLM hop.",
|
|
1083
|
+
"Tip: Shift+Enter or Ctrl+J inserts a newline in composer.",
|
|
1084
|
+
}, "\n")
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
func max(a, b int) int {
|
|
1088
|
+
if a > b {
|
|
1089
|
+
return a
|
|
1090
|
+
}
|
|
1091
|
+
return b
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
func shortInteractivePath(path string) string {
|
|
1095
|
+
trimmed := strings.TrimSpace(strings.ReplaceAll(path, "\\", "/"))
|
|
1096
|
+
if trimmed == "" {
|
|
1097
|
+
return "."
|
|
1098
|
+
}
|
|
1099
|
+
parts := strings.Split(trimmed, "/")
|
|
1100
|
+
filtered := make([]string, 0, len(parts))
|
|
1101
|
+
for _, part := range parts {
|
|
1102
|
+
if strings.TrimSpace(part) == "" {
|
|
1103
|
+
continue
|
|
1104
|
+
}
|
|
1105
|
+
filtered = append(filtered, part)
|
|
1106
|
+
}
|
|
1107
|
+
if len(filtered) <= 3 {
|
|
1108
|
+
return trimmed
|
|
1109
|
+
}
|
|
1110
|
+
return ".../" + strings.Join(filtered[len(filtered)-3:], "/")
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
func naturalRunReply(result *runExecutionResult) string {
|
|
1114
|
+
if result == nil || result.State == nil {
|
|
1115
|
+
return "Run could not be completed; details are in the result card below."
|
|
1116
|
+
}
|
|
1117
|
+
state := result.State
|
|
1118
|
+
if result.Err != nil || state.Status == models.StatusFailed {
|
|
1119
|
+
return "The task failed during execution; check details below."
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if state.Patch == nil || len(state.Patch.Files) == 0 {
|
|
1123
|
+
if state.Review != nil && state.Review.Decision == models.ReviewAccept {
|
|
1124
|
+
return "Request completed and no code changes were required."
|
|
1125
|
+
}
|
|
1126
|
+
return "Request completed; see the run summary below."
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
fileCount := len(state.Patch.Files)
|
|
1130
|
+
if fileCount == 1 {
|
|
1131
|
+
return "Task completed; changes were prepared in 1 file."
|
|
1132
|
+
}
|
|
1133
|
+
return fmt.Sprintf("Task completed; changes were prepared in %d files.", fileCount)
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
func compactRunLines(result *runExecutionResult, verbose bool) []string {
|
|
1137
|
+
if result == nil {
|
|
1138
|
+
return []string{"run failed: no result"}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
lines := make([]string, 0, 12)
|
|
1142
|
+
state := result.State
|
|
1143
|
+
if state == nil {
|
|
1144
|
+
return []string{"run failed: no state returned"}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
lines = append(lines, dracula.accent.Render(fmt.Sprintf("Session %s | Project %s", result.SessionName, result.ProjectID)))
|
|
1148
|
+
|
|
1149
|
+
providerSummary := providerSummaryFromLogs(state.Logs)
|
|
1150
|
+
if providerSummary != "" {
|
|
1151
|
+
lines = append(lines, dracula.accent.Render(providerSummary))
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
duration := "-"
|
|
1155
|
+
if state.CompletedAt != nil {
|
|
1156
|
+
duration = state.CompletedAt.Sub(state.StartedAt).Round(time.Millisecond).String()
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
if result.Err != nil {
|
|
1160
|
+
lines = append(lines, dracula.error.Render(fmt.Sprintf("Run failed (%s): %v", duration, result.Err)))
|
|
1161
|
+
} else {
|
|
1162
|
+
lines = append(lines, dracula.success.Render(fmt.Sprintf("Run completed (%s): %s", duration, state.Status)))
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
if state.Review != nil {
|
|
1166
|
+
reviewLine := fmt.Sprintf("Review: %s", state.Review.Decision)
|
|
1167
|
+
if len(state.Review.Comments) > 0 {
|
|
1168
|
+
reviewLine += " - " + state.Review.Comments[0]
|
|
1169
|
+
}
|
|
1170
|
+
lines = append(lines, reviewLine)
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
timeline := timelineFromLogs(state.Logs)
|
|
1174
|
+
for _, t := range timeline {
|
|
1175
|
+
lines = append(lines, dracula.timeline.Render(t))
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
if strings.TrimSpace(state.BestPatchSummary) != "" {
|
|
1179
|
+
lines = append(lines, "Patch: "+state.BestPatchSummary)
|
|
1180
|
+
}
|
|
1181
|
+
if strings.TrimSpace(state.TestResults) != "" {
|
|
1182
|
+
lines = append(lines, "Tests: completed")
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
lines = append(lines, fmt.Sprintf("Run ID: %s", state.ID))
|
|
1186
|
+
lines = append(lines, fmt.Sprintf("Log: .orch/runs/%s.json", state.ID))
|
|
1187
|
+
|
|
1188
|
+
for _, warning := range result.Warnings {
|
|
1189
|
+
lines = append(lines, dracula.warning.Render("warning: "+warning))
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
if verbose {
|
|
1193
|
+
lines = append(lines, "--- details ---")
|
|
1194
|
+
for _, entry := range state.Logs {
|
|
1195
|
+
lines = append(lines, fmt.Sprintf("[%s] %s", entry.Actor, entry.Message))
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
return lines
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
func providerSummaryFromLogs(entries []models.LogEntry) string {
|
|
1203
|
+
provider := ""
|
|
1204
|
+
for _, entry := range entries {
|
|
1205
|
+
if entry.Actor != "provider" {
|
|
1206
|
+
continue
|
|
1207
|
+
}
|
|
1208
|
+
if entry.Step == "status" {
|
|
1209
|
+
provider = entry.Message
|
|
1210
|
+
break
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
if strings.TrimSpace(provider) == "" {
|
|
1214
|
+
return ""
|
|
1215
|
+
}
|
|
1216
|
+
return "Provider: " + provider
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
func timelineFromLogs(entries []models.LogEntry) []string {
|
|
1220
|
+
if len(entries) == 0 {
|
|
1221
|
+
return nil
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
stages := []struct {
|
|
1225
|
+
actor string
|
|
1226
|
+
label string
|
|
1227
|
+
}{
|
|
1228
|
+
{actor: "analyzer", label: "Analyze"},
|
|
1229
|
+
{actor: "planner", label: "Plan"},
|
|
1230
|
+
{actor: "coder", label: "Build"},
|
|
1231
|
+
{actor: "test", label: "Test"},
|
|
1232
|
+
{actor: "reviewer", label: "Review"},
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
lines := make([]string, 0, len(stages))
|
|
1236
|
+
for _, stage := range stages {
|
|
1237
|
+
start := findLogTime(entries, stage.actor)
|
|
1238
|
+
if start.IsZero() {
|
|
1239
|
+
continue
|
|
1240
|
+
}
|
|
1241
|
+
end := findNextTime(entries, start)
|
|
1242
|
+
delta := "-"
|
|
1243
|
+
if !end.IsZero() {
|
|
1244
|
+
delta = end.Sub(start).Round(time.Millisecond).String()
|
|
1245
|
+
}
|
|
1246
|
+
lines = append(lines, fmt.Sprintf("• %s %s", stage.label, delta))
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
return lines
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
func findLogTime(entries []models.LogEntry, actor string) time.Time {
|
|
1253
|
+
for _, e := range entries {
|
|
1254
|
+
if e.Actor == actor {
|
|
1255
|
+
return e.Timestamp
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
return time.Time{}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
func findNextTime(entries []models.LogEntry, current time.Time) time.Time {
|
|
1262
|
+
for _, e := range entries {
|
|
1263
|
+
if e.Timestamp.After(current) {
|
|
1264
|
+
return e.Timestamp
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
return time.Time{}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
func shortModelsLine(modelsLine string) string {
|
|
1271
|
+
line := strings.TrimSpace(modelsLine)
|
|
1272
|
+
if line == "" {
|
|
1273
|
+
return "models: -"
|
|
1274
|
+
}
|
|
1275
|
+
line = strings.TrimPrefix(line, "Models: ")
|
|
1276
|
+
parts := strings.Fields(line)
|
|
1277
|
+
if len(parts) == 0 {
|
|
1278
|
+
return "models: -"
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
val := map[string]string{}
|
|
1282
|
+
for _, p := range parts {
|
|
1283
|
+
kv := strings.SplitN(p, "=", 2)
|
|
1284
|
+
if len(kv) != 2 {
|
|
1285
|
+
continue
|
|
1286
|
+
}
|
|
1287
|
+
val[kv[0]] = kv[1]
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
planner := val["planner"]
|
|
1291
|
+
coder := val["coder"]
|
|
1292
|
+
reviewer := val["reviewer"]
|
|
1293
|
+
if planner != "" && planner == coder && planner == reviewer {
|
|
1294
|
+
return "model " + planner
|
|
1295
|
+
}
|
|
1296
|
+
if planner == "" {
|
|
1297
|
+
planner = "-"
|
|
1298
|
+
}
|
|
1299
|
+
if coder == "" {
|
|
1300
|
+
coder = "-"
|
|
1301
|
+
}
|
|
1302
|
+
if reviewer == "" {
|
|
1303
|
+
reviewer = "-"
|
|
1304
|
+
}
|
|
1305
|
+
return fmt.Sprintf("models p:%s c:%s r:%s", planner, coder, reviewer)
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
type runtimeStatus struct {
|
|
1309
|
+
providerLine string
|
|
1310
|
+
authLine string
|
|
1311
|
+
modelsLine string
|
|
1312
|
+
providerConfigured bool
|
|
1313
|
+
authConnected bool
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
func runtimeStatusSnapshot() runtimeStatus {
|
|
1317
|
+
status := runtimeStatus{
|
|
1318
|
+
providerLine: "Provider: unknown",
|
|
1319
|
+
authLine: "Auth: disconnected",
|
|
1320
|
+
modelsLine: "Models: planner=- coder=- reviewer=-",
|
|
1321
|
+
providerConfigured: false,
|
|
1322
|
+
authConnected: false,
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
cwd, err := os.Getwd()
|
|
1326
|
+
if err != nil {
|
|
1327
|
+
return status
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
providerState, err := providers.ReadState(cwd)
|
|
1331
|
+
if err != nil {
|
|
1332
|
+
return status
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
cfg, err := config.Load(cwd)
|
|
1336
|
+
if err != nil {
|
|
1337
|
+
return status
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
status.providerLine = fmt.Sprintf("Provider: %s", cfg.Provider.Default)
|
|
1341
|
+
status.modelsLine = fmt.Sprintf("Models: planner=%s coder=%s reviewer=%s", cfg.Provider.OpenAI.Models.Planner, cfg.Provider.OpenAI.Models.Coder, cfg.Provider.OpenAI.Models.Reviewer)
|
|
1342
|
+
status.providerConfigured = cfg.Provider.Flags.OpenAIEnabled && strings.ToLower(strings.TrimSpace(cfg.Provider.Default)) == "openai"
|
|
1343
|
+
status.authConnected = providerState.OpenAI.Connected
|
|
1344
|
+
if providerState.OpenAI.Connected {
|
|
1345
|
+
status.authLine = fmt.Sprintf("Auth: connected (%s %s)", providerState.OpenAI.Mode, providerState.OpenAI.Source)
|
|
1346
|
+
} else {
|
|
1347
|
+
mode := providerState.OpenAI.Mode
|
|
1348
|
+
if mode == "" {
|
|
1349
|
+
mode = "unknown"
|
|
1350
|
+
}
|
|
1351
|
+
if strings.TrimSpace(providerState.OpenAI.Reason) != "" {
|
|
1352
|
+
status.authLine = fmt.Sprintf("Auth: disconnected (%s) - %s", mode, providerState.OpenAI.Reason)
|
|
1353
|
+
} else {
|
|
1354
|
+
status.authLine = fmt.Sprintf("Auth: disconnected (%s)", mode)
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
return status
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
func readRuntimeStatus() (providerLine, authLine, modelsLine string) {
|
|
1362
|
+
status := runtimeStatusSnapshot()
|
|
1363
|
+
return status.providerLine, status.authLine, status.modelsLine
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
func generateSessionID() string {
|
|
1367
|
+
buf := make([]byte, 8)
|
|
1368
|
+
if _, err := rand.Read(buf); err != nil {
|
|
1369
|
+
return fmt.Sprintf("ses_%d", time.Now().UnixNano())
|
|
1370
|
+
}
|
|
1371
|
+
return "ses_" + hex.EncodeToString(buf)
|
|
1372
|
+
}
|