@makefinks/daemon 0.9.1 → 0.11.0

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 (40) hide show
  1. package/README.md +60 -14
  2. package/package.json +4 -2
  3. package/src/ai/copilot-client.ts +775 -0
  4. package/src/ai/daemon-ai.ts +32 -234
  5. package/src/ai/model-config.ts +55 -14
  6. package/src/ai/providers/capabilities.ts +16 -0
  7. package/src/ai/providers/copilot-provider.ts +632 -0
  8. package/src/ai/providers/openrouter-provider.ts +217 -0
  9. package/src/ai/providers/registry.ts +14 -0
  10. package/src/ai/providers/types.ts +31 -0
  11. package/src/ai/system-prompt.ts +16 -0
  12. package/src/ai/tools/subagents.ts +1 -1
  13. package/src/ai/tools/tool-registry.ts +22 -1
  14. package/src/ai/tools/write-file.ts +51 -0
  15. package/src/app/components/AppOverlays.tsx +9 -1
  16. package/src/app/components/ConversationPane.tsx +8 -2
  17. package/src/components/ModelMenu.tsx +202 -140
  18. package/src/components/OnboardingOverlay.tsx +147 -1
  19. package/src/components/SettingsMenu.tsx +27 -1
  20. package/src/components/TokenUsageDisplay.tsx +5 -3
  21. package/src/components/tool-layouts/layouts/index.ts +1 -0
  22. package/src/components/tool-layouts/layouts/write-file.tsx +117 -0
  23. package/src/hooks/daemon-event-handlers.ts +61 -14
  24. package/src/hooks/keyboard-handlers.ts +109 -28
  25. package/src/hooks/use-app-callbacks.ts +141 -43
  26. package/src/hooks/use-app-context-builder.ts +5 -0
  27. package/src/hooks/use-app-controller.ts +31 -2
  28. package/src/hooks/use-app-copilot-models-loader.ts +45 -0
  29. package/src/hooks/use-app-display-state.ts +24 -2
  30. package/src/hooks/use-app-model.ts +103 -17
  31. package/src/hooks/use-app-preferences-bootstrap.ts +54 -10
  32. package/src/hooks/use-bootstrap-controller.ts +5 -0
  33. package/src/hooks/use-daemon-events.ts +8 -2
  34. package/src/hooks/use-daemon-keyboard.ts +19 -6
  35. package/src/hooks/use-daemon-runtime-controller.ts +4 -0
  36. package/src/hooks/use-menu-keyboard.ts +6 -1
  37. package/src/state/app-context.tsx +6 -0
  38. package/src/types/index.ts +24 -1
  39. package/src/utils/copilot-models.ts +77 -0
  40. package/src/utils/preferences.ts +3 -0
@@ -2,7 +2,7 @@ import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core";
2
2
  import { useKeyboard } from "@opentui/react";
3
3
  import { useEffect, useMemo, useRef, useState } from "react";
4
4
  import { useMenuKeyboard } from "../hooks/use-menu-keyboard";
5
- import type { ModelOption } from "../types";
5
+ import type { LlmProvider, ModelOption } from "../types";
6
6
  import { COLORS } from "../ui/constants";
7
7
  import { formatContextWindowK, formatPrice } from "../utils/formatters";
8
8
 
@@ -20,6 +20,7 @@ const MIN_ALL_MODEL_QUERY_LENGTH = 3;
20
20
  interface ModelMenuProps {
21
21
  curatedModels: ModelOption[];
22
22
  allModels: ModelOption[];
23
+ modelProvider: LlmProvider;
23
24
  allModelsLoading: boolean;
24
25
  allModelsUpdatedAt: number | null;
25
26
  currentModelId: string;
@@ -36,6 +37,7 @@ function formatUpdatedAt(timestamp: number | null): string {
36
37
  export function ModelMenu({
37
38
  curatedModels,
38
39
  allModels,
40
+ modelProvider,
39
41
  allModelsLoading,
40
42
  allModelsUpdatedAt,
41
43
  currentModelId,
@@ -46,35 +48,38 @@ export function ModelMenu({
46
48
  const [searchQuery, setSearchQuery] = useState("");
47
49
  const [isSearchFocused, setIsSearchFocused] = useState(false);
48
50
  const searchInputRef = useRef<TextareaRenderable | null>(null);
51
+ const isCopilotProvider = modelProvider === "copilot";
49
52
 
50
53
  const sortedCurated = useMemo(() => {
54
+ if (isCopilotProvider) return [];
51
55
  return [...curatedModels].sort((a, b) => {
52
56
  const priceA = a.pricing ? a.pricing.prompt + a.pricing.completion : Number.MAX_SAFE_INTEGER;
53
57
  const priceB = b.pricing ? b.pricing.prompt + b.pricing.completion : Number.MAX_SAFE_INTEGER;
54
58
  if (priceA !== priceB) return priceA - priceB;
55
59
  return a.name.localeCompare(b.name);
56
60
  });
57
- }, [curatedModels]);
61
+ }, [curatedModels, isCopilotProvider]);
58
62
 
59
63
  const curatedIdSet = useMemo(() => new Set(sortedCurated.map((model) => model.id)), [sortedCurated]);
60
64
 
65
+ const allModelsWithFallback = useMemo(() => {
66
+ if (!currentModelId) return allModels;
67
+ if (allModels.some((model) => model.id === currentModelId)) return allModels;
68
+ return [...allModels, { id: currentModelId, name: currentModelId }];
69
+ }, [allModels, currentModelId]);
70
+
61
71
  const savedModel = useMemo(() => {
72
+ if (isCopilotProvider) return null;
62
73
  if (!currentModelId) return null;
63
74
  if (curatedIdSet.has(currentModelId)) return null;
64
- const match = allModels.find((model) => model.id === currentModelId);
75
+ const match = allModelsWithFallback.find((model) => model.id === currentModelId);
65
76
  return match ?? { id: currentModelId, name: currentModelId };
66
- }, [allModels, curatedIdSet, currentModelId]);
77
+ }, [allModelsWithFallback, curatedIdSet, currentModelId, isCopilotProvider]);
67
78
 
68
79
  const savedModels = useMemo(() => (savedModel ? [savedModel] : []), [savedModel]);
69
80
 
70
- const allModelsWithFallback = useMemo(() => {
71
- if (!currentModelId) return allModels;
72
- if (curatedIdSet.has(currentModelId)) return allModels;
73
- if (savedModel) return allModels;
74
- return [...allModels, { id: currentModelId, name: currentModelId }];
75
- }, [allModels, curatedIdSet, currentModelId, savedModel]);
76
-
77
81
  const filteredAllModels = useMemo(() => {
82
+ if (isCopilotProvider) return [];
78
83
  const filtered = allModelsWithFallback.filter(
79
84
  (model) => !curatedIdSet.has(model.id) && model.id !== savedModel?.id
80
85
  );
@@ -82,63 +87,55 @@ export function ModelMenu({
82
87
  if (query.length < MIN_ALL_MODEL_QUERY_LENGTH) {
83
88
  return [];
84
89
  }
90
+ const matching = filtered.filter(
91
+ (model) => model.name.toLowerCase().includes(query) || model.id.toLowerCase().includes(query)
92
+ );
93
+ return matching.sort((a, b) => a.name.localeCompare(b.name));
94
+ }, [allModelsWithFallback, curatedIdSet, isCopilotProvider, savedModel?.id, searchQuery]);
95
+
96
+ const copilotModels = useMemo(() => {
97
+ if (!isCopilotProvider) return [];
98
+ const query = searchQuery.trim().toLowerCase();
85
99
  const matching = query
86
- ? filtered.filter(
100
+ ? allModelsWithFallback.filter(
87
101
  (model) => model.name.toLowerCase().includes(query) || model.id.toLowerCase().includes(query)
88
102
  )
89
- : filtered;
103
+ : allModelsWithFallback;
104
+ return [...matching].sort((a, b) => a.name.localeCompare(b.name));
105
+ }, [allModelsWithFallback, isCopilotProvider, searchQuery]);
90
106
 
91
- return matching.sort((a, b) => a.name.localeCompare(b.name));
92
- }, [allModelsWithFallback, curatedIdSet, savedModel?.id, searchQuery]);
107
+ const menuItems = useMemo(() => {
108
+ if (isCopilotProvider) return copilotModels;
109
+ return [...sortedCurated, ...savedModels, ...filteredAllModels];
110
+ }, [copilotModels, filteredAllModels, isCopilotProvider, savedModels, sortedCurated]);
93
111
 
94
- const totalItems = sortedCurated.length + savedModels.length + filteredAllModels.length;
112
+ const totalItems = menuItems.length;
95
113
 
96
114
  const initialIndex = useMemo(() => {
97
115
  if (totalItems === 0) return 0;
98
- const curatedIdx = sortedCurated.findIndex((model) => model.id === currentModelId);
99
- if (curatedIdx >= 0) return curatedIdx;
100
- const savedIdx = savedModels.findIndex((model) => model.id === currentModelId);
101
- if (savedIdx >= 0) return sortedCurated.length + savedIdx;
102
- const allIdx = filteredAllModels.findIndex((model) => model.id === currentModelId);
103
- if (allIdx >= 0) return sortedCurated.length + savedModels.length + allIdx;
104
- return 0;
105
- }, [sortedCurated, savedModels, filteredAllModels, currentModelId, totalItems]);
116
+ const idx = menuItems.findIndex((model) => model.id === currentModelId);
117
+ return idx >= 0 ? idx : 0;
118
+ }, [currentModelId, menuItems, totalItems]);
106
119
 
107
120
  const { selectedIndex } = useMenuKeyboard({
108
121
  itemCount: totalItems,
109
122
  initialIndex,
110
123
  onClose,
111
124
  onSelect: (selectedIdx) => {
112
- if (selectedIdx < sortedCurated.length) {
113
- const model = sortedCurated[selectedIdx];
114
- if (model) {
115
- onSelect(model);
116
- }
117
- return;
118
- }
119
-
120
- const afterCurated = selectedIdx - sortedCurated.length;
121
- if (afterCurated < savedModels.length) {
122
- const model = savedModels[afterCurated];
123
- if (model) {
124
- onSelect(model);
125
- }
126
- return;
127
- }
128
-
129
- const model = filteredAllModels[afterCurated - savedModels.length];
125
+ const model = menuItems[selectedIdx];
130
126
  if (model) {
131
127
  onSelect(model);
132
128
  }
133
129
  },
134
130
  enableViKeys: !isSearchFocused,
135
131
  ignoreEscape: isSearchFocused,
132
+ disabled: isSearchFocused,
136
133
  });
137
134
 
138
135
  useKeyboard((key) => {
139
136
  if (key.eventType !== "press") return;
140
137
 
141
- if (!isSearchFocused && (key.name === "r" || key.sequence?.toLowerCase() === "r")) {
138
+ if (!isCopilotProvider && !isSearchFocused && (key.name === "r" || key.sequence?.toLowerCase() === "r")) {
142
139
  onRefreshAllModels();
143
140
  key.preventDefault();
144
141
  return;
@@ -151,13 +148,16 @@ export function ModelMenu({
151
148
  }
152
149
  });
153
150
 
154
- const allSelectedIndex = selectedIndex - sortedCurated.length - savedModels.length;
151
+ const scrollModels = isCopilotProvider ? copilotModels : filteredAllModels;
152
+ const allSelectedIndex = isCopilotProvider
153
+ ? selectedIndex
154
+ : selectedIndex - sortedCurated.length - savedModels.length;
155
155
  const isAllSectionSelected = allSelectedIndex >= 0;
156
156
 
157
157
  const scrollRef = useRef<ScrollBoxRenderable | null>(null);
158
158
  const scrollboxHeight = Math.min(
159
159
  MAX_ALL_SCROLLBOX_HEIGHT,
160
- Math.max(ALL_MODEL_ITEM_HEIGHT, filteredAllModels.length * ALL_MODEL_ITEM_HEIGHT)
160
+ Math.max(ALL_MODEL_ITEM_HEIGHT, scrollModels.length * ALL_MODEL_ITEM_HEIGHT)
161
161
  );
162
162
 
163
163
  useEffect(() => {
@@ -184,9 +184,10 @@ export function ModelMenu({
184
184
  if (nextTop !== currentTop) {
185
185
  scrollbox.scrollTop = nextTop;
186
186
  }
187
- }, [allSelectedIndex, filteredAllModels.length, isAllSectionSelected]);
187
+ }, [allSelectedIndex, isAllSectionSelected, scrollModels.length]);
188
188
 
189
189
  const updatedAtLabel = formatUpdatedAt(allModelsUpdatedAt);
190
+ const needsSearchHint = !isCopilotProvider && searchQuery.trim().length < MIN_ALL_MODEL_QUERY_LENGTH;
190
191
 
191
192
  const renderModelRow = (model: ModelOption, isSelected: boolean, isCurrent: boolean) => {
192
193
  const pricing = model.pricing;
@@ -262,7 +263,10 @@ export function ModelMenu({
262
263
  </box>
263
264
  <box marginBottom={1}>
264
265
  <text>
265
- <span fg={COLORS.USER_LABEL}>↑/↓ or j/k navigate · ENTER select · R refresh · ESC cancel</span>
266
+ <span fg={COLORS.USER_LABEL}>
267
+ ↑/↓ or j/k navigate · ENTER select
268
+ {isCopilotProvider ? "" : " · R refresh"} · ESC cancel
269
+ </span>
266
270
  </text>
267
271
  </box>
268
272
  <box marginBottom={1}>
@@ -273,7 +277,7 @@ export function ModelMenu({
273
277
 
274
278
  <box marginBottom={0}>
275
279
  <text>
276
- <span fg={COLORS.USER_LABEL}>— SEARCH ALL MODELS —</span>
280
+ <span fg={COLORS.USER_LABEL}>— SEARCH MODELS —</span>
277
281
  </text>
278
282
  </box>
279
283
 
@@ -337,118 +341,176 @@ export function ModelMenu({
337
341
  </box>
338
342
  ) : null}
339
343
 
340
- <box marginBottom={1} marginTop={1}>
341
- <text>
342
- <span fg={COLORS.DAEMON_LABEL}>[ RECOMMENDED ]</span>
343
- </text>
344
- </box>
345
-
346
- {sortedCurated.length === 0 ? (
347
- <box marginBottom={1} paddingLeft={1}>
348
- <text>
349
- <span fg={COLORS.REASONING_DIM}>No curated models available</span>
350
- </text>
351
- </box>
352
- ) : (
344
+ {isCopilotProvider ? (
353
345
  <>
354
- <box marginBottom={1}>
355
- <box flexDirection="row" justifyContent="space-between">
356
- <text>
357
- <span fg={COLORS.REASONING_DIM}>MODEL</span>
358
- </text>
346
+ <box marginBottom={1} marginTop={1}>
347
+ <text>
348
+ <span fg={COLORS.DAEMON_LABEL}>[ MODELS ]</span>
349
+ {allModelsLoading ? <span fg={COLORS.REASONING_DIM}> (loading...)</span> : null}
350
+ </text>
351
+ </box>
352
+ {copilotModels.length === 0 ? (
353
+ <box marginTop={0} paddingLeft={1}>
359
354
  <text>
360
355
  <span fg={COLORS.REASONING_DIM}>
361
- {"CTX".padStart(COL_WIDTH.CTX)} {"IN".padStart(COL_WIDTH.IN)}{" "}
362
- {"OUT".padStart(COL_WIDTH.OUT)} {"CACHE".padStart(COL_WIDTH.CACHE)}
356
+ {allModelsLoading ? "Loading models..." : "No models found"}
363
357
  </span>
364
358
  </text>
365
359
  </box>
366
- </box>
367
- <box flexDirection="column">
368
- {sortedCurated.map((model, idx) =>
369
- renderModelRow(model, idx === selectedIndex, model.id === currentModelId)
370
- )}
371
- </box>
360
+ ) : (
361
+ <>
362
+ <box marginBottom={1}>
363
+ <box flexDirection="row" justifyContent="space-between">
364
+ <text>
365
+ <span fg={COLORS.REASONING_DIM}>MODEL</span>
366
+ </text>
367
+ <text>
368
+ <span fg={COLORS.REASONING_DIM}>
369
+ {"CTX".padStart(COL_WIDTH.CTX)} {"IN".padStart(COL_WIDTH.IN)}{" "}
370
+ {"OUT".padStart(COL_WIDTH.OUT)} {"CACHE".padStart(COL_WIDTH.CACHE)}
371
+ </span>
372
+ </text>
373
+ </box>
374
+ </box>
375
+ <scrollbox
376
+ ref={scrollRef}
377
+ height={scrollboxHeight}
378
+ alignSelf="flex-start"
379
+ focused={false}
380
+ scrollY={true}
381
+ scrollX={false}
382
+ style={{
383
+ rootOptions: { backgroundColor: COLORS.MENU_BG },
384
+ wrapperOptions: { backgroundColor: COLORS.MENU_BG },
385
+ viewportOptions: { backgroundColor: COLORS.MENU_BG },
386
+ contentOptions: { backgroundColor: COLORS.MENU_BG },
387
+ }}
388
+ >
389
+ <box flexDirection="column">
390
+ {copilotModels.map((model, idx) =>
391
+ renderModelRow(model, idx === selectedIndex, model.id === currentModelId)
392
+ )}
393
+ </box>
394
+ </scrollbox>
395
+ </>
396
+ )}
372
397
  </>
373
- )}
374
-
375
- {savedModels.length > 0 ? (
398
+ ) : (
376
399
  <>
377
400
  <box marginBottom={1} marginTop={1}>
378
401
  <text>
379
- <span fg={COLORS.DAEMON_LABEL}>[ SAVED ]</span>
402
+ <span fg={COLORS.DAEMON_LABEL}>[ RECOMMENDED ]</span>
380
403
  </text>
381
404
  </box>
382
- <box flexDirection="column">
383
- {savedModels.map((model, idx) =>
384
- renderModelRow(
385
- model,
386
- sortedCurated.length + idx === selectedIndex,
387
- model.id === currentModelId
388
- )
389
- )}
390
- </box>
391
- </>
392
- ) : null}
393
405
 
394
- <box marginBottom={1} marginTop={1}>
395
- <text>
396
- <span fg={COLORS.DAEMON_LABEL}>[ ALL MODELS ]</span>
397
- {allModelsLoading ? <span fg={COLORS.REASONING_DIM}> (refreshing...)</span> : null}
398
- </text>
399
- </box>
400
-
401
- {filteredAllModels.length === 0 ? (
402
- <box marginTop={0} paddingLeft={1}>
403
- <text>
404
- <span fg={COLORS.REASONING_DIM}>
405
- {searchQuery.trim().length < MIN_ALL_MODEL_QUERY_LENGTH
406
- ? `Type ${MIN_ALL_MODEL_QUERY_LENGTH}+ characters to search`
407
- : allModelsLoading
408
- ? "Loading models..."
409
- : "No models found"}
410
- </span>
411
- </text>
412
- </box>
413
- ) : (
414
- <>
415
- <box marginBottom={1}>
416
- <box flexDirection="row" justifyContent="space-between">
406
+ {sortedCurated.length === 0 ? (
407
+ <box marginBottom={1} paddingLeft={1}>
417
408
  <text>
418
- <span fg={COLORS.REASONING_DIM}>MODEL</span>
409
+ <span fg={COLORS.REASONING_DIM}>No curated models available</span>
419
410
  </text>
411
+ </box>
412
+ ) : (
413
+ <>
414
+ <box marginBottom={1}>
415
+ <box flexDirection="row" justifyContent="space-between">
416
+ <text>
417
+ <span fg={COLORS.REASONING_DIM}>MODEL</span>
418
+ </text>
419
+ <text>
420
+ <span fg={COLORS.REASONING_DIM}>
421
+ {"CTX".padStart(COL_WIDTH.CTX)} {"IN".padStart(COL_WIDTH.IN)}{" "}
422
+ {"OUT".padStart(COL_WIDTH.OUT)} {"CACHE".padStart(COL_WIDTH.CACHE)}
423
+ </span>
424
+ </text>
425
+ </box>
426
+ </box>
427
+ <box flexDirection="column">
428
+ {sortedCurated.map((model, idx) =>
429
+ renderModelRow(model, idx === selectedIndex, model.id === currentModelId)
430
+ )}
431
+ </box>
432
+ </>
433
+ )}
434
+
435
+ {savedModels.length > 0 ? (
436
+ <>
437
+ <box marginBottom={1} marginTop={1}>
438
+ <text>
439
+ <span fg={COLORS.DAEMON_LABEL}>[ SAVED ]</span>
440
+ </text>
441
+ </box>
442
+ <box flexDirection="column">
443
+ {savedModels.map((model, idx) =>
444
+ renderModelRow(
445
+ model,
446
+ sortedCurated.length + idx === selectedIndex,
447
+ model.id === currentModelId
448
+ )
449
+ )}
450
+ </box>
451
+ </>
452
+ ) : null}
453
+
454
+ <box marginBottom={1} marginTop={1}>
455
+ <text>
456
+ <span fg={COLORS.DAEMON_LABEL}>[ ALL MODELS ]</span>
457
+ {allModelsLoading ? <span fg={COLORS.REASONING_DIM}> (refreshing...)</span> : null}
458
+ </text>
459
+ </box>
460
+
461
+ {filteredAllModels.length === 0 ? (
462
+ <box marginTop={0} paddingLeft={1}>
420
463
  <text>
421
464
  <span fg={COLORS.REASONING_DIM}>
422
- {"CTX".padStart(COL_WIDTH.CTX)} {"IN".padStart(COL_WIDTH.IN)}{" "}
423
- {"OUT".padStart(COL_WIDTH.OUT)} {"CACHE".padStart(COL_WIDTH.CACHE)}
465
+ {needsSearchHint
466
+ ? `Type ${MIN_ALL_MODEL_QUERY_LENGTH}+ characters to search`
467
+ : allModelsLoading
468
+ ? "Loading models..."
469
+ : "No models found"}
424
470
  </span>
425
471
  </text>
426
472
  </box>
427
- </box>
428
- <scrollbox
429
- ref={scrollRef}
430
- height={scrollboxHeight}
431
- alignSelf="flex-start"
432
- focused={false}
433
- scrollY={true}
434
- scrollX={false}
435
- style={{
436
- rootOptions: { backgroundColor: COLORS.MENU_BG },
437
- wrapperOptions: { backgroundColor: COLORS.MENU_BG },
438
- viewportOptions: { backgroundColor: COLORS.MENU_BG },
439
- contentOptions: { backgroundColor: COLORS.MENU_BG },
440
- }}
441
- >
442
- <box flexDirection="column">
443
- {filteredAllModels.map((model, idx) =>
444
- renderModelRow(
445
- model,
446
- sortedCurated.length + idx === selectedIndex,
447
- model.id === currentModelId
448
- )
449
- )}
450
- </box>
451
- </scrollbox>
473
+ ) : (
474
+ <>
475
+ <box marginBottom={1}>
476
+ <box flexDirection="row" justifyContent="space-between">
477
+ <text>
478
+ <span fg={COLORS.REASONING_DIM}>MODEL</span>
479
+ </text>
480
+ <text>
481
+ <span fg={COLORS.REASONING_DIM}>
482
+ {"CTX".padStart(COL_WIDTH.CTX)} {"IN".padStart(COL_WIDTH.IN)}{" "}
483
+ {"OUT".padStart(COL_WIDTH.OUT)} {"CACHE".padStart(COL_WIDTH.CACHE)}
484
+ </span>
485
+ </text>
486
+ </box>
487
+ </box>
488
+ <scrollbox
489
+ ref={scrollRef}
490
+ height={scrollboxHeight}
491
+ alignSelf="flex-start"
492
+ focused={false}
493
+ scrollY={true}
494
+ scrollX={false}
495
+ style={{
496
+ rootOptions: { backgroundColor: COLORS.MENU_BG },
497
+ wrapperOptions: { backgroundColor: COLORS.MENU_BG },
498
+ viewportOptions: { backgroundColor: COLORS.MENU_BG },
499
+ contentOptions: { backgroundColor: COLORS.MENU_BG },
500
+ }}
501
+ >
502
+ <box flexDirection="column">
503
+ {filteredAllModels.map((model, idx) =>
504
+ renderModelRow(
505
+ model,
506
+ sortedCurated.length + savedModels.length + idx === selectedIndex,
507
+ model.id === currentModelId
508
+ )
509
+ )}
510
+ </box>
511
+ </scrollbox>
512
+ </>
513
+ )}
452
514
  </>
453
515
  )}
454
516
  </box>