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.
Files changed (116) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +624 -0
  4. package/cmd/apply.go +111 -0
  5. package/cmd/auth.go +393 -0
  6. package/cmd/auth_test.go +100 -0
  7. package/cmd/diff.go +57 -0
  8. package/cmd/doctor.go +149 -0
  9. package/cmd/explain.go +192 -0
  10. package/cmd/explain_test.go +62 -0
  11. package/cmd/init.go +100 -0
  12. package/cmd/interactive.go +1372 -0
  13. package/cmd/interactive_input.go +45 -0
  14. package/cmd/interactive_input_test.go +55 -0
  15. package/cmd/logs.go +72 -0
  16. package/cmd/model.go +84 -0
  17. package/cmd/plan.go +149 -0
  18. package/cmd/provider.go +189 -0
  19. package/cmd/provider_model_doctor_test.go +91 -0
  20. package/cmd/root.go +67 -0
  21. package/cmd/run.go +123 -0
  22. package/cmd/run_engine.go +208 -0
  23. package/cmd/run_engine_test.go +30 -0
  24. package/cmd/session.go +589 -0
  25. package/cmd/session_helpers.go +54 -0
  26. package/cmd/session_integration_test.go +30 -0
  27. package/cmd/session_list_current_test.go +87 -0
  28. package/cmd/session_messages_test.go +163 -0
  29. package/cmd/session_runs_test.go +68 -0
  30. package/cmd/sprint1_integration_test.go +119 -0
  31. package/cmd/stats.go +173 -0
  32. package/cmd/stats_test.go +71 -0
  33. package/cmd/version.go +4 -0
  34. package/go.mod +45 -0
  35. package/go.sum +108 -0
  36. package/internal/agents/agent.go +31 -0
  37. package/internal/agents/coder.go +167 -0
  38. package/internal/agents/planner.go +155 -0
  39. package/internal/agents/reviewer.go +118 -0
  40. package/internal/agents/runtime.go +25 -0
  41. package/internal/agents/runtime_test.go +77 -0
  42. package/internal/auth/account.go +78 -0
  43. package/internal/auth/oauth.go +523 -0
  44. package/internal/auth/store.go +287 -0
  45. package/internal/confidence/policy.go +174 -0
  46. package/internal/confidence/policy_test.go +71 -0
  47. package/internal/confidence/scorer.go +253 -0
  48. package/internal/confidence/scorer_test.go +83 -0
  49. package/internal/config/config.go +331 -0
  50. package/internal/config/config_defaults_test.go +138 -0
  51. package/internal/execution/contract_builder.go +160 -0
  52. package/internal/execution/contract_builder_test.go +68 -0
  53. package/internal/execution/plan_compliance.go +161 -0
  54. package/internal/execution/plan_compliance_test.go +71 -0
  55. package/internal/execution/retry_directive.go +132 -0
  56. package/internal/execution/scope_guard.go +69 -0
  57. package/internal/logger/logger.go +120 -0
  58. package/internal/models/contracts_test.go +100 -0
  59. package/internal/models/models.go +269 -0
  60. package/internal/orchestrator/orchestrator.go +701 -0
  61. package/internal/orchestrator/orchestrator_retry_test.go +135 -0
  62. package/internal/orchestrator/review_engine_test.go +50 -0
  63. package/internal/orchestrator/state.go +42 -0
  64. package/internal/orchestrator/test_classifier_test.go +68 -0
  65. package/internal/patch/applier.go +131 -0
  66. package/internal/patch/applier_test.go +25 -0
  67. package/internal/patch/parser.go +89 -0
  68. package/internal/patch/patch.go +60 -0
  69. package/internal/patch/summary.go +30 -0
  70. package/internal/patch/validator.go +104 -0
  71. package/internal/planning/normalizer.go +416 -0
  72. package/internal/planning/normalizer_test.go +64 -0
  73. package/internal/providers/errors.go +35 -0
  74. package/internal/providers/openai/client.go +498 -0
  75. package/internal/providers/openai/client_test.go +187 -0
  76. package/internal/providers/provider.go +47 -0
  77. package/internal/providers/registry.go +32 -0
  78. package/internal/providers/registry_test.go +57 -0
  79. package/internal/providers/router.go +52 -0
  80. package/internal/providers/state.go +114 -0
  81. package/internal/providers/state_test.go +64 -0
  82. package/internal/repo/analyzer.go +188 -0
  83. package/internal/repo/context.go +83 -0
  84. package/internal/review/engine.go +267 -0
  85. package/internal/review/engine_test.go +103 -0
  86. package/internal/runstore/store.go +137 -0
  87. package/internal/runstore/store_test.go +59 -0
  88. package/internal/runtime/lock.go +150 -0
  89. package/internal/runtime/lock_test.go +57 -0
  90. package/internal/session/compaction.go +260 -0
  91. package/internal/session/compaction_test.go +36 -0
  92. package/internal/session/service.go +117 -0
  93. package/internal/session/service_test.go +113 -0
  94. package/internal/storage/storage.go +1498 -0
  95. package/internal/storage/storage_test.go +413 -0
  96. package/internal/testing/classifier.go +80 -0
  97. package/internal/testing/classifier_test.go +36 -0
  98. package/internal/tools/command.go +160 -0
  99. package/internal/tools/command_test.go +56 -0
  100. package/internal/tools/file.go +111 -0
  101. package/internal/tools/git.go +77 -0
  102. package/internal/tools/invalid_params_test.go +36 -0
  103. package/internal/tools/policy.go +98 -0
  104. package/internal/tools/policy_test.go +36 -0
  105. package/internal/tools/registry_test.go +52 -0
  106. package/internal/tools/result.go +30 -0
  107. package/internal/tools/search.go +86 -0
  108. package/internal/tools/tool.go +94 -0
  109. package/main.go +9 -0
  110. package/npm/orch.js +25 -0
  111. package/package.json +41 -0
  112. package/scripts/changelog.js +20 -0
  113. package/scripts/check-release-version.js +21 -0
  114. package/scripts/lib/release-utils.js +223 -0
  115. package/scripts/postinstall.js +157 -0
  116. 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
+ }