@orbit-intelligence/orbit-agent 0.3.12

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 (80) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +23 -0
  3. package/bin/orbit +26 -0
  4. package/dist/prompts/system.js +80 -0
  5. package/dist/src/cli/args.js +145 -0
  6. package/dist/src/cli/orchestrate.js +100 -0
  7. package/dist/src/cli/run.js +393 -0
  8. package/dist/src/config/config-schema.js +151 -0
  9. package/dist/src/config/index.js +57 -0
  10. package/dist/src/core/agent/agent-loop.js +402 -0
  11. package/dist/src/core/agents/delegate.js +120 -0
  12. package/dist/src/core/agents/orchestrator.js +58 -0
  13. package/dist/src/core/agents/prompts.js +82 -0
  14. package/dist/src/core/agents/types.js +1 -0
  15. package/dist/src/core/context/context-manager.js +167 -0
  16. package/dist/src/core/events.js +23 -0
  17. package/dist/src/core/llm/http.js +207 -0
  18. package/dist/src/core/llm/index.js +93 -0
  19. package/dist/src/core/llm/models.js +228 -0
  20. package/dist/src/core/llm/providers/gemini.js +211 -0
  21. package/dist/src/core/llm/providers/openai-compat.js +31 -0
  22. package/dist/src/core/llm/router.js +125 -0
  23. package/dist/src/core/llm/secrets.js +121 -0
  24. package/dist/src/core/llm/types.js +10 -0
  25. package/dist/src/core/orchestration/dispatcher.js +74 -0
  26. package/dist/src/core/orchestration/messenger.js +139 -0
  27. package/dist/src/core/orchestration/roles.js +129 -0
  28. package/dist/src/core/orchestration/runtime.js +122 -0
  29. package/dist/src/core/orchestration/session.js +204 -0
  30. package/dist/src/core/orchestration/shared-context.js +88 -0
  31. package/dist/src/core/orchestration/tools.js +187 -0
  32. package/dist/src/core/orchestration/types.js +3 -0
  33. package/dist/src/core/permissions/index.js +58 -0
  34. package/dist/src/core/project-context.js +115 -0
  35. package/dist/src/core/skill-loader.js +31 -0
  36. package/dist/src/core/tools/edit.js +142 -0
  37. package/dist/src/core/tools/filesystem.js +203 -0
  38. package/dist/src/core/tools/git.js +138 -0
  39. package/dist/src/core/tools/registry.js +73 -0
  40. package/dist/src/core/tools/search.js +90 -0
  41. package/dist/src/core/tools/shell.js +65 -0
  42. package/dist/src/core/tools/types.js +6 -0
  43. package/dist/src/core/types.js +3 -0
  44. package/dist/src/index.js +11 -0
  45. package/dist/src/session/event-log.js +55 -0
  46. package/dist/src/session/store.js +76 -0
  47. package/dist/src/setup/wizard.js +401 -0
  48. package/dist/src/tui/InkApp.js +67 -0
  49. package/dist/src/tui/ansi.js +142 -0
  50. package/dist/src/tui/app.js +768 -0
  51. package/dist/src/tui/colors.js +13 -0
  52. package/dist/src/tui/components/AgentDock.js +46 -0
  53. package/dist/src/tui/components/Composer.js +35 -0
  54. package/dist/src/tui/components/Header.js +23 -0
  55. package/dist/src/tui/components/ModelPicker.js +23 -0
  56. package/dist/src/tui/components/PermissionModal.js +29 -0
  57. package/dist/src/tui/components/SlashMenu.js +15 -0
  58. package/dist/src/tui/components/StatusLine.js +27 -0
  59. package/dist/src/tui/components/Transcript.js +31 -0
  60. package/dist/src/tui/components/WorkingStatus.js +29 -0
  61. package/dist/src/tui/components/input.js +246 -0
  62. package/dist/src/tui/components/markdown.js +384 -0
  63. package/dist/src/tui/components/message.js +105 -0
  64. package/dist/src/tui/context.js +8 -0
  65. package/dist/src/tui/geometry.js +40 -0
  66. package/dist/src/tui/renderer.js +116 -0
  67. package/dist/src/tui/rows.js +247 -0
  68. package/dist/src/tui/scheduler.js +32 -0
  69. package/dist/src/tui/store.js +127 -0
  70. package/dist/src/tui/style.js +151 -0
  71. package/dist/src/tui/term.js +309 -0
  72. package/dist/src/tui/text.js +104 -0
  73. package/dist/src/tui/themes/index.js +15 -0
  74. package/dist/src/tui/themes/palettes.js +137 -0
  75. package/dist/src/tui/themes/types.js +1 -0
  76. package/dist/src/utils/diff.js +161 -0
  77. package/dist/src/utils/platform.js +71 -0
  78. package/dist/src/utils/signals.js +26 -0
  79. package/dist/src/version.js +4 -0
  80. package/package.json +71 -0
@@ -0,0 +1,768 @@
1
+ import React from 'react';
2
+ import { render } from 'ink';
3
+ import { providerNames } from '../config/config-schema.js';
4
+ import { makeTheme, THEME_NAMES } from './themes/index.js';
5
+ import { AppStore, ASK_OPTIONS } from './store.js';
6
+ import { InkApp } from './InkApp.js';
7
+ export class TuiApp {
8
+ store = new AppStore();
9
+ theme;
10
+ onSubmit;
11
+ onCommand;
12
+ version;
13
+ config;
14
+ bus;
15
+ models;
16
+ instance = null;
17
+ done = false;
18
+ /** Last dispatched key (sig + timestamp) for de-duping keyboard repeats. */
19
+ lastKey = null;
20
+ /** Paces the streaming reveal cursor so output reads word-to-word. */
21
+ revealTimer = null;
22
+ constructor(opts) {
23
+ this.config = opts.config;
24
+ this.bus = opts.bus;
25
+ this.onSubmit = opts.onSubmit;
26
+ this.onCommand = opts.onCommand ?? (async () => null);
27
+ this.version = opts.version;
28
+ this.models = opts.models ?? [];
29
+ this.theme = makeTheme(opts.config.theme);
30
+ this.store.version = opts.version;
31
+ this.store.cwd = process.cwd();
32
+ this.store.theme = this.theme;
33
+ this.store.models = [...this.models];
34
+ this.store.thinkingDefaultOpen = opts.config.ui.showThinking ?? true;
35
+ this.store.yolo = opts.config.permissions.mode === 'allow';
36
+ this.subscribeBus();
37
+ }
38
+ run() {
39
+ const ctrl = {
40
+ store: this.store,
41
+ handleKey: (input, key) => this.handleKey(input, key),
42
+ refresh: () => this.store.refresh(),
43
+ };
44
+ this.instance = render(React.createElement(InkApp, { controller: ctrl }), {
45
+ // Alternate screen (like opencode/OpenTUI): swaps in a clean frame on
46
+ // start and restores the user's shell content on exit. Native scrollback
47
+ // would require committing rows via <Static>; kept as a future option.
48
+ alternateScreen: true,
49
+ exitOnCtrlC: false,
50
+ patchConsole: false,
51
+ });
52
+ // Word-pacing tick: advance the reveal cursor while a turn is streaming
53
+ // and redraw only when the visible content moved.
54
+ this.revealTimer = setInterval(() => {
55
+ if (this.store.advanceReveal())
56
+ this.store.refresh();
57
+ }, 120);
58
+ }
59
+ destroy() {
60
+ if (this.revealTimer != null) {
61
+ clearInterval(this.revealTimer);
62
+ this.revealTimer = null;
63
+ }
64
+ if (this.done)
65
+ return;
66
+ this.done = true;
67
+ this.instance?.unmount();
68
+ this.instance = null;
69
+ }
70
+ exit(code) {
71
+ this.destroy();
72
+ setImmediate(() => process.exit(code));
73
+ }
74
+ pushMessage(msg) {
75
+ this.store.messages.push(msg);
76
+ this.store.scrollOffset = 0;
77
+ this.store.refresh();
78
+ }
79
+ getMessages() {
80
+ return this.store.messages;
81
+ }
82
+ ask(prompt) {
83
+ return new Promise((resolve) => {
84
+ this.store.pendingAsk = { prompt, index: 0, resolve };
85
+ this.store.input.clear();
86
+ this.store.refresh();
87
+ });
88
+ }
89
+ // ------------------------------------------------------------------
90
+ // Event bus subscription
91
+ // ------------------------------------------------------------------
92
+ subscribeBus() {
93
+ this.bus.on('onAssistantStart', (msg) => {
94
+ this.store.streaming = true;
95
+ this.store.cancelArmed = false;
96
+ this.store.beginStream();
97
+ if (this.store.planningTurn && this.store.plan) {
98
+ msg.isPlan = true;
99
+ }
100
+ this.pushMessage(msg);
101
+ });
102
+ this.bus.on('onToken', (text) => {
103
+ this.store.outputTokens += 1;
104
+ // The agent loop already streams into the same ChatMessage object that
105
+ // lives in this.store.messages — appending again here would double every
106
+ // token. Only mark it live and re-render.
107
+ const msg = this.store.findStreaming();
108
+ if (msg) {
109
+ msg.streaming = true;
110
+ }
111
+ void text;
112
+ this.store.refresh();
113
+ });
114
+ this.bus.on('onThinking', (text) => {
115
+ // Same shared-object semantics as onToken: the loop appends to
116
+ // msg.reasoning directly; we just re-render.
117
+ void text;
118
+ this.store.refresh();
119
+ });
120
+ this.bus.on('onToolStart', (call) => {
121
+ const msg = this.store.findStreaming();
122
+ if (msg) {
123
+ msg.toolCalls = [...(msg.toolCalls ?? []), call];
124
+ }
125
+ this.store.refresh();
126
+ });
127
+ this.bus.on('onToolUpdate', (call) => this.applyToolState(call));
128
+ this.bus.on('onToolEnd', (call) => this.applyToolState(call));
129
+ this.bus.on('onAssistantEnd', (msg) => {
130
+ this.store.streaming = false;
131
+ this.store.cancelArmed = false;
132
+ this.store.revealAll();
133
+ const idx = this.store.messages.findIndex((m) => m.id === msg.id);
134
+ if (idx >= 0)
135
+ this.store.messages[idx] = msg;
136
+ // Capture a finished plan proposal so /run can execute it.
137
+ if (this.store.planningTurn && this.store.plan) {
138
+ this.store.plan.text = msg.content;
139
+ this.store.plan.status = 'proposed';
140
+ this.store.planningTurn = false;
141
+ this.bus.emit('onPlanProposed', { text: msg.content, task: this.store.plan.task });
142
+ }
143
+ this.store.refresh();
144
+ });
145
+ this.bus.on('onRoute', (status) => {
146
+ this.store.route = status;
147
+ if (!this.store.greeted) {
148
+ this.store.greeted = true;
149
+ this.pushMessage({
150
+ id: 'greeting',
151
+ role: 'system',
152
+ content: `orbit-agent v${this.version} · route ${status.provider}/${status.model} · ${status.strategy}. Type /help for commands.`,
153
+ createdAt: Date.now(),
154
+ });
155
+ }
156
+ this.store.refresh();
157
+ });
158
+ this.bus.on('onError', (err) => {
159
+ this.store.streaming = false;
160
+ this.store.cancelArmed = false;
161
+ this.store.revealAll();
162
+ this.pushMessage({
163
+ id: `err-${Date.now()}`,
164
+ role: 'assistant',
165
+ content: `**Error:** ${err.message}`,
166
+ error: err.message,
167
+ createdAt: Date.now(),
168
+ });
169
+ });
170
+ // Diff review: attach captured file diffs to the in-flight assistant turn.
171
+ this.bus.on('onFileDiff', (d) => {
172
+ const msg = this.store.findStreaming() ?? this.lastAssistant();
173
+ if (msg) {
174
+ msg.diffs = [...(msg.diffs ?? []), d];
175
+ }
176
+ this.store.refresh();
177
+ });
178
+ // Plan mode lifecycle.
179
+ this.bus.on('onPlanProposed', ({ text }) => {
180
+ const plan = this.store.plan;
181
+ if (plan) {
182
+ plan.text = text;
183
+ plan.status = 'proposed';
184
+ this.store.planningTurn = false;
185
+ }
186
+ this.store.refresh();
187
+ });
188
+ this.bus.on('onUserMessage', (msg) => {
189
+ // The plan-mode/execute wrappers are scaffolding for the model — show the
190
+ // user a clean version of their turn instead.
191
+ if (msg.content.startsWith('[PLAN MODE]') || msg.content.startsWith('[EXECUTE]')) {
192
+ const taskMatch = msg.content.match(/Task:\s*([^\n]+)/);
193
+ if (taskMatch)
194
+ msg.content = taskMatch[1];
195
+ }
196
+ this.pushMessage(msg);
197
+ });
198
+ // Orchestration events → agent dock state
199
+ this.bus.on('onAgentStart', (detail) => {
200
+ this.store.agents.set(detail.role, { id: detail.role, name: detail.role, status: 'thinking', task: detail.task });
201
+ this.store.refresh();
202
+ });
203
+ this.bus.on('onAgentStatus', (s) => {
204
+ this.store.agents.set(s.id, { id: s.id, name: s.name, status: s.status });
205
+ this.store.refresh();
206
+ });
207
+ this.bus.on('onAgentEnd', (detail) => {
208
+ this.store.agents.set(detail.role, { id: detail.role, name: detail.role, status: 'done', task: detail.summary });
209
+ this.store.refresh();
210
+ });
211
+ }
212
+ applyToolState(call) {
213
+ const msg = this.store.findStreaming();
214
+ if (msg && msg.toolCalls) {
215
+ const idx = msg.toolCalls.findIndex((c) => c.id === call.id);
216
+ if (idx >= 0)
217
+ msg.toolCalls[idx] = call;
218
+ else
219
+ msg.toolCalls = [...msg.toolCalls, call];
220
+ }
221
+ this.store.refresh();
222
+ }
223
+ lastAssistant() {
224
+ for (let i = this.store.messages.length - 1; i >= 0; i--) {
225
+ const m = this.store.messages[i];
226
+ if (m?.role === 'assistant')
227
+ return m;
228
+ }
229
+ return undefined;
230
+ }
231
+ /** Toggle the inline diff view on the most recent message with diffs. */
232
+ toggleDiffs() {
233
+ for (let i = this.store.messages.length - 1; i >= 0; i--) {
234
+ const m = this.store.messages[i];
235
+ if (m && m.diffs && m.diffs.length > 0) {
236
+ m.diffsOpen = !m.diffsOpen;
237
+ this.store.refresh();
238
+ return;
239
+ }
240
+ }
241
+ this.note('No file diffs captured yet in this session.');
242
+ }
243
+ // ------------------------------------------------------------------
244
+ // Keyboard (ink input key handler)
245
+ // ------------------------------------------------------------------
246
+ handleKey = (input, key) => {
247
+ if (this.done)
248
+ return;
249
+ // Android/Termux keyboards occasionally deliver the same key twice in a
250
+ // burst (no keyup). Treat an identical repeat within one debounce window
251
+ // as a single press so text does not double up and typing feels smooth.
252
+ if (this.isKeyRepeat(input, key))
253
+ return;
254
+ const store = this.store;
255
+ // Model picker intercepts all keys while open
256
+ if (store.modelPicker.open) {
257
+ this.handleModelPickerKey(input, key);
258
+ return;
259
+ }
260
+ // Ctrl-C: cancel stream then exit
261
+ if (key.ctrl && input === 'c') {
262
+ if (store.streaming) {
263
+ if (store.cancelArmed)
264
+ this.exit(130);
265
+ store.cancelArmed = true;
266
+ this.requestCancel();
267
+ }
268
+ else if (store.pendingAsk) {
269
+ const { resolve } = store.pendingAsk;
270
+ store.pendingAsk = null;
271
+ resolve(false);
272
+ store.refresh();
273
+ }
274
+ else {
275
+ this.exit(130);
276
+ }
277
+ return;
278
+ }
279
+ // Escape: cancel / deny ask / clear
280
+ if (key.escape) {
281
+ if (store.streaming) {
282
+ store.cancelArmed = true;
283
+ this.requestCancel();
284
+ }
285
+ else if (store.pendingAsk) {
286
+ const { resolve } = store.pendingAsk;
287
+ store.pendingAsk = null;
288
+ resolve(false);
289
+ store.refresh();
290
+ }
291
+ else if (store.input.currentBuffer() !== '') {
292
+ store.input.clear();
293
+ store.refresh();
294
+ }
295
+ return;
296
+ }
297
+ // Permission overlay: navigate with arrows, confirm with Enter / y / n.
298
+ if (store.pendingAsk) {
299
+ const ask = store.pendingAsk;
300
+ if (key.upArrow || key.leftArrow || key.downArrow || key.rightArrow) {
301
+ const dir = key.upArrow || key.leftArrow ? -1 : 1;
302
+ ask.index = (ask.index + dir + ASK_OPTIONS.length) % ASK_OPTIONS.length;
303
+ store.refresh();
304
+ }
305
+ else if (key.return) {
306
+ const ok = ask.index === 0;
307
+ store.pendingAsk = null;
308
+ ask.resolve(ok);
309
+ store.refresh();
310
+ }
311
+ else if (input === 'y' || input === 'Y') {
312
+ store.pendingAsk = null;
313
+ ask.resolve(true);
314
+ store.refresh();
315
+ }
316
+ else if (input === 'n' || input === 'N') {
317
+ store.pendingAsk = null;
318
+ ask.resolve(false);
319
+ store.refresh();
320
+ }
321
+ return;
322
+ }
323
+ // Scrolling even while streaming
324
+ if (key.pageUp) {
325
+ this.scrollUp();
326
+ return;
327
+ }
328
+ if (key.pageDown) {
329
+ this.scrollDown();
330
+ return;
331
+ }
332
+ // Toggle thinking block (Ctrl-T so bare letters stay typeable)
333
+ if (key.ctrl && input === 't') {
334
+ const msg = store.findStreaming() ?? store.messages[store.messages.length - 1];
335
+ if (msg) {
336
+ msg.reasoningOpen = !msg.reasoningOpen;
337
+ store.refresh();
338
+ }
339
+ return;
340
+ }
341
+ // Toggle diff view (Ctrl-D; only when not streaming)
342
+ if (key.ctrl && input === 'd' && !store.streaming) {
343
+ this.toggleDiffs();
344
+ return;
345
+ }
346
+ // Ctrl-O toggle agent dock (moved off Ctrl-T to free it for thinking)
347
+ if (key.ctrl && input === 'o') {
348
+ store.dockOpen = !store.dockOpen;
349
+ store.refresh();
350
+ return;
351
+ }
352
+ // While streaming or pending ask: no editing
353
+ if (store.streaming || store.pendingAsk)
354
+ return;
355
+ // Paste (multi-char input)
356
+ if (input.length > 1 && !key.ctrl && !key.meta) {
357
+ store.input.insertChar(input);
358
+ store.refresh();
359
+ return;
360
+ }
361
+ // Enter / Shift-Enter
362
+ if (key.return) {
363
+ if (key.shift) {
364
+ store.input.insertNewline();
365
+ store.refresh();
366
+ return;
367
+ }
368
+ this.handleEnter();
369
+ return;
370
+ }
371
+ // Tab slash autocomplete
372
+ if (key.tab && !key.shift && store.input.currentBuffer().startsWith('/')) {
373
+ this.handleTabAutocomplete();
374
+ return;
375
+ }
376
+ // Control keys
377
+ if (key.ctrl && input) {
378
+ switch (input) {
379
+ case 'a':
380
+ store.input.moveToStart();
381
+ break;
382
+ case 'e':
383
+ store.input.moveToEnd();
384
+ break;
385
+ case 'k':
386
+ store.input.deleteToEnd();
387
+ break;
388
+ case 'u':
389
+ store.input.deleteToStart();
390
+ break;
391
+ case 'w':
392
+ store.input.deleteWordBack();
393
+ break;
394
+ }
395
+ store.refresh();
396
+ return;
397
+ }
398
+ // Shift-Tab: delete word back (legacy)
399
+ if (key.tab && key.shift) {
400
+ store.input.deleteWordBack();
401
+ store.refresh();
402
+ return;
403
+ }
404
+ if (key.backspace) {
405
+ store.input.backspace();
406
+ store.refresh();
407
+ return;
408
+ }
409
+ if (key.delete) {
410
+ store.input.delete();
411
+ store.refresh();
412
+ return;
413
+ }
414
+ if (key.leftArrow) {
415
+ store.input.moveLeft();
416
+ store.refresh();
417
+ return;
418
+ }
419
+ if (key.rightArrow) {
420
+ store.input.moveRight();
421
+ store.refresh();
422
+ return;
423
+ }
424
+ if (key.upArrow) {
425
+ store.input.moveUp();
426
+ store.refresh();
427
+ return;
428
+ }
429
+ if (key.downArrow) {
430
+ store.input.moveDown();
431
+ store.refresh();
432
+ return;
433
+ }
434
+ if (key.home) {
435
+ store.input.moveToStart();
436
+ store.refresh();
437
+ return;
438
+ }
439
+ if (key.end) {
440
+ store.input.moveToEnd();
441
+ store.refresh();
442
+ return;
443
+ }
444
+ // Printable characters
445
+ if (input && !key.ctrl && !key.meta) {
446
+ store.input.insertChar(input);
447
+ store.refresh();
448
+ }
449
+ };
450
+ isKeyRepeat(input, key) {
451
+ if (key.eventType === 'repeat')
452
+ return true;
453
+ const flags = [
454
+ 'upArrow', 'downArrow', 'leftArrow', 'rightArrow',
455
+ 'pageUp', 'pageDown', 'home', 'end', 'return', 'escape',
456
+ 'tab', 'backspace', 'delete',
457
+ ]
458
+ .filter((f) => key[f])
459
+ .join('|');
460
+ const sig = flags ? `${flags}|${input}` : input;
461
+ if (!sig)
462
+ return false;
463
+ const now = Date.now();
464
+ if (this.lastKey && this.lastKey.sig === sig && now - this.lastKey.at < 45) {
465
+ return true;
466
+ }
467
+ this.lastKey = { sig, at: now };
468
+ return false;
469
+ }
470
+ handleModelPickerKey(input, key) {
471
+ const store = this.store;
472
+ const list = store.models;
473
+ if (list.length === 0) {
474
+ store.modelPicker.open = false;
475
+ store.refresh();
476
+ return;
477
+ }
478
+ if (key.escape) {
479
+ store.modelPicker.open = false;
480
+ store.refresh();
481
+ return;
482
+ }
483
+ if (key.upArrow || (key.tab && key.shift)) {
484
+ store.modelPicker.index = (store.modelPicker.index - 1 + list.length) % list.length;
485
+ store.refresh();
486
+ return;
487
+ }
488
+ if (key.downArrow || key.tab) {
489
+ store.modelPicker.index = (store.modelPicker.index + 1) % list.length;
490
+ store.refresh();
491
+ return;
492
+ }
493
+ if (key.return) {
494
+ const chosen = list[store.modelPicker.index];
495
+ store.modelPicker.open = false;
496
+ store.refresh();
497
+ if (chosen)
498
+ void this.switchModel(chosen);
499
+ return;
500
+ }
501
+ if (input && !key.ctrl && !key.meta)
502
+ store.refresh();
503
+ }
504
+ handleTabAutocomplete() {
505
+ const buf = this.store.input.currentBuffer();
506
+ const token = buf.split(/\s+/)[0] ?? '';
507
+ const matches = this.store.slashMatches();
508
+ if (matches.length === 0)
509
+ return;
510
+ const common = longestCommonPrefix(matches.map((m) => m.cmd));
511
+ if (common.length > token.length) {
512
+ const argPart = buf.slice(token.length);
513
+ this.store.input.clear();
514
+ this.store.input.insertChar(common + argPart);
515
+ this.store.refresh();
516
+ }
517
+ }
518
+ requestCancel() {
519
+ this.bus.emit('onAbortRequest');
520
+ this.store.streaming = false;
521
+ this.store.revealAll();
522
+ }
523
+ handleEnter() {
524
+ const store = this.store;
525
+ if (store.pendingAsk) {
526
+ const answer = store.input.submitBuffer().trim().toLowerCase();
527
+ const ok = store.pendingAsk.index === 0 ? true : /^(y|yes|true|allow|sure)$/i.test(answer);
528
+ const { resolve } = store.pendingAsk;
529
+ store.pendingAsk = null;
530
+ resolve(ok);
531
+ store.refresh();
532
+ return;
533
+ }
534
+ const text = store.input.submitBuffer().trim();
535
+ if (text.length === 0) {
536
+ store.refresh();
537
+ return;
538
+ }
539
+ if (text.startsWith('/')) {
540
+ void this.handleSlash(text);
541
+ return;
542
+ }
543
+ this.onSubmit(text);
544
+ store.refresh();
545
+ }
546
+ // ------------------------------------------------------------------
547
+ // Slash commands
548
+ // ------------------------------------------------------------------
549
+ async handleSlash(raw) {
550
+ const [cmd, ...rest] = raw.slice(1).split(/\s+/);
551
+ const arg = rest.join(' ').trim();
552
+ const store = this.store;
553
+ switch (cmd) {
554
+ case 'exit':
555
+ case 'quit':
556
+ this.exit(0);
557
+ return;
558
+ case 'clear':
559
+ if (store.streaming)
560
+ this.requestCancel();
561
+ store.messages = [];
562
+ store.scrollOffset = 0;
563
+ store.refresh();
564
+ return;
565
+ case 'help':
566
+ this.pushMessage({
567
+ id: `help-${Date.now()}`,
568
+ role: 'system',
569
+ content: [
570
+ `/exit /quit leave orbit`,
571
+ `/clear clear the conversation`,
572
+ `/help show this help`,
573
+ `/model [id] pick a model (bare: interactive selector)`,
574
+ `/provider <id> switch provider (orbitx · groq · gemini · openrouter)`,
575
+ `/plan <task> plan first: propose a plan, approve with /run`,
576
+ `/run execute the proposed plan`,
577
+ `/reject discard the proposed plan`,
578
+ `/diff toggle unified diff view (or press d)`,
579
+ `/skills list project skills (AGENTS.md / .orbit/skills)`,
580
+ `/theme <name> change theme (${THEME_NAMES.join(' · ')})`,
581
+ `/yolo auto-approve tools`,
582
+ `/yes answer a permission prompt`,
583
+ `/agents toggle agent dock (orchestrate mode)`,
584
+ ``,
585
+ `Esc cancels the current stream. Ctrl-C cancels, then exits.`,
586
+ `Ctrl-T toggles the thinking block; Ctrl-D toggles diff review;`,
587
+ `Ctrl-O toggles the agent dock (orchestrate mode).`,
588
+ ].join('\n'),
589
+ createdAt: Date.now(),
590
+ });
591
+ return;
592
+ case 'plan': {
593
+ if (!arg) {
594
+ this.note('Usage: /plan <task> — propose an implementation plan before executing.');
595
+ return;
596
+ }
597
+ if (store.streaming) {
598
+ this.note('Wait for the current turn to finish.');
599
+ return;
600
+ }
601
+ // Switch to plan-then-approve mode. The wrapped prompt forces the
602
+ // model to investigate read-only and return a plan instead of acting.
603
+ store.plan = { task: arg, text: '', status: 'planning' };
604
+ store.planningTurn = true;
605
+ this.onSubmit(`[PLAN MODE] Task: ${arg}\n\nDo NOT modify any files or run any commands yet. Research the codebase (read_file, glob, grep, list_dir are allowed), then propose a concise, numbered implementation plan. End your plan with nothing else — say "Plan approved. Run /run to execute."`);
606
+ this.note(`Planning “${truncateForNote(arg)}”. Type /run to execute the plan, /reject to cancel.`);
607
+ return;
608
+ }
609
+ case 'run': {
610
+ const plan = store.plan;
611
+ if (!plan || plan.status === 'planning' || plan.status === 'rejected') {
612
+ this.note('No approved plan pending. Start with /plan <task>.');
613
+ return;
614
+ }
615
+ if (store.streaming) {
616
+ this.note('Wait for the current turn to finish.');
617
+ return;
618
+ }
619
+ plan.status = 'approved';
620
+ const task = plan.task;
621
+ store.planningTurn = false;
622
+ this.bus.emit('onPlanApproved', task);
623
+ this.onSubmit(`[EXECUTE] The plan you proposed for “${task}” is approved. Implement it now, using tools, and verify your work.`);
624
+ this.note(`Executing plan for “${truncateForNote(task)}”.`);
625
+ return;
626
+ }
627
+ case 'reject': {
628
+ if (!store.plan || store.plan.status === 'rejected') {
629
+ this.note('No pending plan to reject.');
630
+ return;
631
+ }
632
+ store.plan.status = 'rejected';
633
+ store.planningTurn = false;
634
+ this.bus.emit('onPlanRejected', store.plan.task);
635
+ this.note('Plan rejected. Nothing was executed.');
636
+ return;
637
+ }
638
+ case 'diff':
639
+ this.toggleDiffs();
640
+ return;
641
+ case 'skills': {
642
+ const list = store.skills;
643
+ if (list.length === 0) {
644
+ this.note('No project skills found. Drop markdown files into .orbit/skills/ (or AGENTS.md) to teach orbit your conventions.');
645
+ }
646
+ else {
647
+ this.note(`Project skills:${list.map((s) => `\n ${s.name} — ${s.summary}`).join('')}`);
648
+ }
649
+ return;
650
+ }
651
+ case 'theme': {
652
+ const name = arg.toLowerCase().replace(/[ _]/g, '-');
653
+ if (!THEME_NAMES.includes(name)) {
654
+ this.note(`Unknown theme "${arg}". Try: ${THEME_NAMES.join(', ')}`);
655
+ return;
656
+ }
657
+ this.theme = makeTheme(name);
658
+ store.theme = this.theme;
659
+ this.config.theme = name;
660
+ void this.onCommand({ type: 'theme', value: name });
661
+ this.note(`Theme → ${name}`);
662
+ return;
663
+ }
664
+ case 'model':
665
+ if (!arg) {
666
+ this.store.modelPicker.open = true;
667
+ this.store.modelPicker.index = Math.max(0, this.models.findIndex((m) => m === this.store.route?.model || m.endsWith(`/${this.config.model.primary}`)));
668
+ this.store.refresh();
669
+ return;
670
+ }
671
+ await this.switchModel(arg);
672
+ return;
673
+ case 'provider':
674
+ await this.switchProvider(arg);
675
+ return;
676
+ case 'yolo':
677
+ await this.onCommand({ type: 'yolo' });
678
+ store.yolo = true;
679
+ this.note('Permission mode → allow (yolo).');
680
+ return;
681
+ case 'yes':
682
+ if (store.pendingAsk) {
683
+ const { resolve } = store.pendingAsk;
684
+ store.pendingAsk = null;
685
+ resolve(true);
686
+ store.refresh();
687
+ }
688
+ else {
689
+ this.note('No pending prompt to confirm.');
690
+ }
691
+ return;
692
+ case 'agents':
693
+ store.dockOpen = !store.dockOpen;
694
+ this.note(store.dockOpen ? 'Agent dock opened.' : 'Agent dock closed.');
695
+ return;
696
+ default:
697
+ this.note(`Unknown command /${cmd}. Type /help.`);
698
+ return;
699
+ }
700
+ }
701
+ async switchModel(arg) {
702
+ if (!arg) {
703
+ const hint = this.models.slice(0, 8).join(' ');
704
+ this.note(`Usage: /model <id>${this.models.length > 0 ? `\nAvailable: ${hint}${this.models.length > 8 ? ' …' : ''}` : ''}`);
705
+ return;
706
+ }
707
+ const id = this.resolveModelId(arg);
708
+ if (!id) {
709
+ this.note(`No model matches "${arg}". Available: ${this.models.join(', ') || '(none)'}`);
710
+ return;
711
+ }
712
+ const result = await this.onCommand({ type: 'model', value: id });
713
+ if (result === null)
714
+ this.note(`Model → ${id}`);
715
+ }
716
+ resolveModelId(arg) {
717
+ if (this.models.includes(arg))
718
+ return arg;
719
+ const bare = arg.split('/').pop();
720
+ return this.models.find((x) => x === arg || x.split('/').pop() === bare || x.includes(arg)) ?? null;
721
+ }
722
+ async switchProvider(arg) {
723
+ const p = arg.toLowerCase();
724
+ if (!providerNames.includes(p)) {
725
+ this.note(`Unknown provider "${arg}". Try: ${providerNames.join(', ')}`);
726
+ return;
727
+ }
728
+ const result = await this.onCommand({ type: 'provider', value: p });
729
+ this.note(result ?? `Provider → ${p}`);
730
+ }
731
+ note(text) {
732
+ this.pushMessage({
733
+ id: `note-${Date.now()}`,
734
+ role: 'system',
735
+ content: text,
736
+ createdAt: Date.now(),
737
+ });
738
+ }
739
+ scrollUp() {
740
+ const vis = Math.max(10, this.store.messages.length); // viewport unknown here, rough estimate
741
+ this.store.scrollOffset = Math.min(this.store.scrollOffset + Math.max(3, Math.floor(vis / 2)), 5000);
742
+ this.store.refresh();
743
+ }
744
+ scrollDown() {
745
+ this.store.scrollOffset = Math.max(0, this.store.scrollOffset - Math.max(3, Math.floor(Math.max(10, this.store.messages.length) / 2)));
746
+ this.store.refresh();
747
+ }
748
+ }
749
+ function longestCommonPrefix(strings) {
750
+ if (strings.length === 0)
751
+ return '';
752
+ if (strings.length === 1)
753
+ return strings[0];
754
+ let prefix = '';
755
+ const first = strings[0];
756
+ for (let i = 0; i < first.length; i++) {
757
+ const ch = first[i];
758
+ if (strings.every((s) => s[i] === ch))
759
+ prefix += ch;
760
+ else
761
+ break;
762
+ }
763
+ return prefix;
764
+ }
765
+ function truncateForNote(text, max = 60) {
766
+ const t = text.replace(/\s+/g, ' ').trim();
767
+ return t.length <= max ? t : `${t.slice(0, max)}…`;
768
+ }