@kirosnn/mosaic 0.0.7

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 (154) hide show
  1. package/.mosaic/mosaic.local.jsonc +0 -0
  2. package/MOSAIC.md +188 -0
  3. package/README.md +127 -0
  4. package/docs/mosaic.png +0 -0
  5. package/package.json +42 -0
  6. package/src/agent/Agent.ts +131 -0
  7. package/src/agent/context.ts +96 -0
  8. package/src/agent/index.ts +2 -0
  9. package/src/agent/prompts/systemPrompt.ts +138 -0
  10. package/src/agent/prompts/toolsPrompt.ts +139 -0
  11. package/src/agent/provider/anthropic.ts +122 -0
  12. package/src/agent/provider/google.ts +124 -0
  13. package/src/agent/provider/mistral.ts +117 -0
  14. package/src/agent/provider/ollama.ts +531 -0
  15. package/src/agent/provider/openai.ts +220 -0
  16. package/src/agent/provider/xai.ts +122 -0
  17. package/src/agent/tools/bash.ts +20 -0
  18. package/src/agent/tools/definitions.ts +27 -0
  19. package/src/agent/tools/edit.ts +23 -0
  20. package/src/agent/tools/executor.ts +751 -0
  21. package/src/agent/tools/explore.ts +18 -0
  22. package/src/agent/tools/exploreExecutor.ts +320 -0
  23. package/src/agent/tools/glob.ts +16 -0
  24. package/src/agent/tools/grep.ts +19 -0
  25. package/src/agent/tools/index.ts +4 -0
  26. package/src/agent/tools/list.ts +20 -0
  27. package/src/agent/tools/question.ts +20 -0
  28. package/src/agent/tools/read.ts +15 -0
  29. package/src/agent/tools/write.ts +21 -0
  30. package/src/agent/types.ts +155 -0
  31. package/src/components/App.tsx +174 -0
  32. package/src/components/CommandsModal.tsx +77 -0
  33. package/src/components/CustomInput.tsx +328 -0
  34. package/src/components/Main.tsx +1112 -0
  35. package/src/components/Notification.tsx +91 -0
  36. package/src/components/SelectList.tsx +47 -0
  37. package/src/components/Setup.tsx +528 -0
  38. package/src/components/ShortcutsModal.tsx +67 -0
  39. package/src/components/Welcome.tsx +39 -0
  40. package/src/components/main/ApprovalPanel.tsx +134 -0
  41. package/src/components/main/ChatPage.tsx +516 -0
  42. package/src/components/main/HomePage.tsx +111 -0
  43. package/src/components/main/QuestionPanel.tsx +85 -0
  44. package/src/components/main/ThinkingIndicator.tsx +101 -0
  45. package/src/components/main/types.ts +55 -0
  46. package/src/components/main/wrapText.ts +41 -0
  47. package/src/index.tsx +212 -0
  48. package/src/utils/approvalBridge.ts +129 -0
  49. package/src/utils/commands/echo.ts +22 -0
  50. package/src/utils/commands/help.ts +25 -0
  51. package/src/utils/commands/index.ts +68 -0
  52. package/src/utils/commands/init.ts +68 -0
  53. package/src/utils/commands/redo.ts +74 -0
  54. package/src/utils/commands/registry.ts +29 -0
  55. package/src/utils/commands/sessions.ts +129 -0
  56. package/src/utils/commands/types.ts +20 -0
  57. package/src/utils/commands/undo.ts +75 -0
  58. package/src/utils/commands/web.ts +77 -0
  59. package/src/utils/config.ts +357 -0
  60. package/src/utils/diff.ts +201 -0
  61. package/src/utils/diffRendering.tsx +62 -0
  62. package/src/utils/exploreBridge.ts +87 -0
  63. package/src/utils/fileChangeTracker.ts +98 -0
  64. package/src/utils/fileChangesBridge.ts +18 -0
  65. package/src/utils/history.ts +106 -0
  66. package/src/utils/markdown.tsx +232 -0
  67. package/src/utils/models.ts +304 -0
  68. package/src/utils/questionBridge.ts +122 -0
  69. package/src/utils/terminalUtils.ts +25 -0
  70. package/src/utils/toolFormatting.ts +384 -0
  71. package/src/utils/undoRedo.ts +429 -0
  72. package/src/utils/undoRedoBridge.ts +45 -0
  73. package/src/utils/undoRedoDb.ts +338 -0
  74. package/src/utils/uninstall.ts +45 -0
  75. package/src/utils/version.ts +3 -0
  76. package/src/web/app.tsx +606 -0
  77. package/src/web/assets/css/ChatPage.css +212 -0
  78. package/src/web/assets/css/FileExplorer.css +202 -0
  79. package/src/web/assets/css/HomePage.css +119 -0
  80. package/src/web/assets/css/Markdown.css +178 -0
  81. package/src/web/assets/css/MessageItem.css +160 -0
  82. package/src/web/assets/css/Sidebar.css +208 -0
  83. package/src/web/assets/css/SidebarModal.css +137 -0
  84. package/src/web/assets/css/ThinkingIndicator.css +47 -0
  85. package/src/web/assets/css/ToolMessage.css +148 -0
  86. package/src/web/assets/css/global.css +226 -0
  87. package/src/web/assets/fonts/Geist-Black.woff2 +0 -0
  88. package/src/web/assets/fonts/Geist-BlackItalic.woff2 +0 -0
  89. package/src/web/assets/fonts/Geist-Bold.woff2 +0 -0
  90. package/src/web/assets/fonts/Geist-BoldItalic.woff2 +0 -0
  91. package/src/web/assets/fonts/Geist-ExtraBold.woff2 +0 -0
  92. package/src/web/assets/fonts/Geist-ExtraBoldItalic.woff2 +0 -0
  93. package/src/web/assets/fonts/Geist-ExtraLight.woff2 +0 -0
  94. package/src/web/assets/fonts/Geist-ExtraLightItalic.woff2 +0 -0
  95. package/src/web/assets/fonts/Geist-Italic[wght].woff2 +0 -0
  96. package/src/web/assets/fonts/Geist-Light.woff2 +0 -0
  97. package/src/web/assets/fonts/Geist-LightItalic.woff2 +0 -0
  98. package/src/web/assets/fonts/Geist-Medium.woff2 +0 -0
  99. package/src/web/assets/fonts/Geist-MediumItalic.woff2 +0 -0
  100. package/src/web/assets/fonts/Geist-Regular.woff2 +0 -0
  101. package/src/web/assets/fonts/Geist-RegularItalic.woff2 +0 -0
  102. package/src/web/assets/fonts/Geist-SemiBold.woff2 +0 -0
  103. package/src/web/assets/fonts/Geist-SemiBoldItalic.woff2 +0 -0
  104. package/src/web/assets/fonts/Geist-Thin.woff2 +0 -0
  105. package/src/web/assets/fonts/Geist-ThinItalic.woff2 +0 -0
  106. package/src/web/assets/fonts/GeistMono-Black.woff2 +0 -0
  107. package/src/web/assets/fonts/GeistMono-BlackItalic.woff2 +0 -0
  108. package/src/web/assets/fonts/GeistMono-Bold.woff2 +0 -0
  109. package/src/web/assets/fonts/GeistMono-BoldItalic.woff2 +0 -0
  110. package/src/web/assets/fonts/GeistMono-ExtraBold.woff2 +0 -0
  111. package/src/web/assets/fonts/GeistMono-ExtraBoldItalic.woff2 +0 -0
  112. package/src/web/assets/fonts/GeistMono-ExtraLight.woff2 +0 -0
  113. package/src/web/assets/fonts/GeistMono-ExtraLightItalic.woff2 +0 -0
  114. package/src/web/assets/fonts/GeistMono-Italic.woff2 +0 -0
  115. package/src/web/assets/fonts/GeistMono-Italic[wght].woff2 +0 -0
  116. package/src/web/assets/fonts/GeistMono-Light.woff2 +0 -0
  117. package/src/web/assets/fonts/GeistMono-LightItalic.woff2 +0 -0
  118. package/src/web/assets/fonts/GeistMono-Medium.woff2 +0 -0
  119. package/src/web/assets/fonts/GeistMono-MediumItalic.woff2 +0 -0
  120. package/src/web/assets/fonts/GeistMono-Regular.woff2 +0 -0
  121. package/src/web/assets/fonts/GeistMono-SemiBold.woff2 +0 -0
  122. package/src/web/assets/fonts/GeistMono-SemiBoldItalic.woff2 +0 -0
  123. package/src/web/assets/fonts/GeistMono-Thin.woff2 +0 -0
  124. package/src/web/assets/fonts/GeistMono-ThinItalic.woff2 +0 -0
  125. package/src/web/assets/fonts/GeistMono[wght].woff2 +0 -0
  126. package/src/web/assets/fonts/Geist[wght].woff2 +0 -0
  127. package/src/web/assets/fonts/blauer-nue-regular.woff2 +0 -0
  128. package/src/web/assets/fonts/neue-montreal-regular.woff2 +0 -0
  129. package/src/web/assets/images/favicon-v2.svg +6 -0
  130. package/src/web/assets/images/favicon.png +0 -0
  131. package/src/web/assets/images/foruse.svg +5 -0
  132. package/src/web/assets/images/logo_black.svg +5 -0
  133. package/src/web/assets/images/logo_white.svg +5 -0
  134. package/src/web/assets/images/logoblack.png +0 -0
  135. package/src/web/assets/images/logowhite.png +0 -0
  136. package/src/web/build.ts +23 -0
  137. package/src/web/components/ApprovalPanel.tsx +191 -0
  138. package/src/web/components/ChatPage.tsx +273 -0
  139. package/src/web/components/FileExplorer.tsx +162 -0
  140. package/src/web/components/HomePage.tsx +121 -0
  141. package/src/web/components/MessageItem.tsx +178 -0
  142. package/src/web/components/Modal.tsx +30 -0
  143. package/src/web/components/QuestionPanel.tsx +149 -0
  144. package/src/web/components/Setup.tsx +211 -0
  145. package/src/web/components/Sidebar.tsx +292 -0
  146. package/src/web/components/ThinkingIndicator.tsx +85 -0
  147. package/src/web/logo_black.svg +5 -0
  148. package/src/web/logo_white.svg +5 -0
  149. package/src/web/router.ts +46 -0
  150. package/src/web/server.tsx +662 -0
  151. package/src/web/storage.ts +92 -0
  152. package/src/web/types.ts +17 -0
  153. package/src/web/utils.ts +61 -0
  154. package/tsconfig.json +33 -0
@@ -0,0 +1,178 @@
1
+ /** @jsxImportSource react */
2
+ import React from 'react';
3
+ import ReactMarkdown from 'react-markdown';
4
+ import remarkGfm from 'remark-gfm';
5
+ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
6
+ import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
7
+ import { Message } from '../types';
8
+ import { parseDiffLine, getDiffLineColors } from '../utils';
9
+ import '../assets/css/global.css'
10
+
11
+ function BlendIcon() {
12
+ return (
13
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className="blend-icon">
14
+ <circle cx="12" cy="6.5" r="1.4"/>
15
+ <circle cx="17.5" cy="12" r="1.4"/>
16
+ <circle cx="12" cy="17.5" r="1.4"/>
17
+ <circle cx="6.5" cy="12" r="1.4"/>
18
+ </svg>
19
+ );
20
+ }
21
+
22
+ function formatBlendTime(ms: number): string {
23
+ const totalSeconds = Math.floor(ms / 1000);
24
+ const minutes = Math.floor(totalSeconds / 60);
25
+ const seconds = totalSeconds % 60;
26
+
27
+ if (minutes >= 60) {
28
+ const hours = Math.floor(minutes / 60);
29
+ const remainingMinutes = minutes % 60;
30
+ return `${hours}h ${remainingMinutes}m`;
31
+ }
32
+
33
+ if (minutes > 0) {
34
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
35
+ }
36
+
37
+ return `${seconds}s`;
38
+ }
39
+
40
+ interface MessageItemProps {
41
+ message: Message;
42
+ }
43
+
44
+ function renderDiffLine(line: string, index: number): React.ReactElement {
45
+ const parsed = parseDiffLine(line);
46
+
47
+ if (!parsed.isDiffLine) {
48
+ return (
49
+ <div key={index} className="tool-line">
50
+ {line}
51
+ </div>
52
+ );
53
+ }
54
+
55
+ const colors = getDiffLineColors(parsed);
56
+
57
+ return (
58
+ <div
59
+ key={index}
60
+ className={`tool-line diff-line ${parsed.isAdded ? 'added' : ''} ${parsed.isRemoved ? 'removed' : ''}`}
61
+ style={{ backgroundColor: colors.contentBackground }}
62
+ >
63
+ <span
64
+ className="diff-label"
65
+ style={{ backgroundColor: colors.labelBackground }}
66
+ >
67
+ {parsed.prefix}{parsed.lineNumber?.padStart(4, ' ')}
68
+ </span>
69
+ <span className="diff-separator">|</span>
70
+ <span className="diff-content">{parsed.content}</span>
71
+ </div>
72
+ );
73
+ }
74
+
75
+ function parseToolHeader(content: string): { name: string; info: string | null; bodyLines: string[] } {
76
+ const lines = content.split('\n');
77
+ const firstLine = lines[0] || '';
78
+ const bodyLines = lines.slice(1);
79
+
80
+ const match = firstLine.match(/^(.+?)\s*\((.+)\)$/);
81
+ if (match) {
82
+ return { name: match[1]!, info: match[2]!, bodyLines };
83
+ }
84
+
85
+ return { name: firstLine, info: null, bodyLines };
86
+ }
87
+
88
+ export function MessageItem({ message }: MessageItemProps) {
89
+ if (message.role === 'tool') {
90
+ const statusClass = message.success === false ? 'error' : message.isRunning ? 'running' : 'success';
91
+
92
+ const { name, info, bodyLines } = parseToolHeader(message.content);
93
+
94
+ return (
95
+ <div className={`message tool ${statusClass}`}>
96
+ <div className="message-content">
97
+ <div className="tool-header">
98
+ <span className={`tool-name ${message.toolName === 'stop' ? 'no-bold' : ''}`}>{name}</span>
99
+ {info && <span className="tool-info">({info})</span>}
100
+ {message.isRunning && message.runningStartTime && (
101
+ <span className="tool-timer">
102
+ {Math.floor((Date.now() - (message.runningStartTime || 0)) / 1000)}s
103
+ </span>
104
+ )}
105
+ </div>
106
+ {bodyLines.length > 0 && (
107
+ <div className="tool-output">
108
+ {bodyLines.map((line, index) => renderDiffLine(line, index))}
109
+ </div>
110
+ )}
111
+ </div>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ if (message.role === 'assistant') {
117
+ const showBlend = message.responseDuration && message.responseDuration > 60000;
118
+
119
+ return (
120
+ <>
121
+ <div className="message assistant">
122
+ <div className="message-content">
123
+ {message.thinkingContent && (
124
+ <details className="thinking-section">
125
+ <summary>Thinking...</summary>
126
+ <pre className="thinking-content">{message.thinkingContent}</pre>
127
+ </details>
128
+ )}
129
+ <div className="markdown-content">
130
+ <ReactMarkdown
131
+ remarkPlugins={[remarkGfm]}
132
+ components={{
133
+ code({ node, className, children, ...props }) {
134
+ const match = /language-(\w+)/.exec(className || '');
135
+ return match ? (
136
+ <SyntaxHighlighter
137
+ style={vscDarkPlus}
138
+ language={match[1]}
139
+ PreTag="div"
140
+ {...props}
141
+ >
142
+ {String(children).replace(/\n$/, '')}
143
+ </SyntaxHighlighter>
144
+ ) : (
145
+ <code className={className} {...props}>
146
+ {children}
147
+ </code>
148
+ );
149
+ }
150
+ }}
151
+ >
152
+ {message.content}
153
+ </ReactMarkdown>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ {showBlend && (
158
+ <div className="message assistant blend-message">
159
+ <div className="message-content blend-indicator">
160
+ <BlendIcon />
161
+ <span className="blend-text">
162
+ {message.blendWord || 'Blended'} for {formatBlendTime(message.responseDuration!)}
163
+ </span>
164
+ </div>
165
+ </div>
166
+ )}
167
+ </>
168
+ );
169
+ }
170
+
171
+ return (
172
+ <div className={`message ${message.role} ${message.isError ? 'error' : ''}`}>
173
+ <div className="message-content">
174
+ {message.displayContent || message.content}
175
+ </div>
176
+ </div>
177
+ );
178
+ }
@@ -0,0 +1,30 @@
1
+ import React from 'react';
2
+ import '../assets/css/global.css';
3
+
4
+ interface ModalProps {
5
+ isOpen: boolean;
6
+ onClose: () => void;
7
+ title: string;
8
+ children: React.ReactNode;
9
+ className?: string;
10
+ }
11
+
12
+ export function Modal({ isOpen, onClose, title, children, className = '' }: ModalProps) {
13
+ if (!isOpen) return null;
14
+
15
+ return (
16
+ <div className="modal-overlay" onClick={onClose}>
17
+ <div className={`modal-content ${className}`} onClick={(e) => e.stopPropagation()}>
18
+ <div className="modal-header">
19
+ <h2>{title}</h2>
20
+ <button className="close-btn" onClick={onClose}>
21
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
22
+ </button>
23
+ </div>
24
+ <div className="modal-body">
25
+ {children}
26
+ </div>
27
+ </div>
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,149 @@
1
+ /** @jsxImportSource react */
2
+ import React, { useState, useEffect, useRef } from 'react';
3
+ import { QuestionRequest } from '../../utils/questionBridge';
4
+
5
+ interface QuestionPanelProps {
6
+ request: QuestionRequest;
7
+ onAnswer: (index: number, customText?: string) => void;
8
+ }
9
+
10
+ export function QuestionPanel({ request, onAnswer }: QuestionPanelProps) {
11
+ const [selectedIndex, setSelectedIndex] = useState(0);
12
+ const [customText, setCustomText] = useState('');
13
+ const inputRef = useRef<HTMLInputElement>(null);
14
+
15
+ useEffect(() => {
16
+ setSelectedIndex(0);
17
+ setCustomText('');
18
+ if (inputRef.current) inputRef.current.focus();
19
+ }, [request.id]);
20
+
21
+ const handleKeyDown = (e: React.KeyboardEvent) => {
22
+ if (e.key === 'ArrowUp') {
23
+ e.preventDefault();
24
+ setSelectedIndex(prev => (prev === 0 ? request.options.length - 1 : prev - 1));
25
+ } else if (e.key === 'ArrowDown') {
26
+ e.preventDefault();
27
+ setSelectedIndex(prev => (prev === request.options.length - 1 ? 0 : prev + 1));
28
+ } else if (e.key === 'Enter') {
29
+ e.preventDefault();
30
+ if (customText.trim()) {
31
+ onAnswer(0, customText);
32
+ } else {
33
+ onAnswer(selectedIndex);
34
+ }
35
+ } else if (e.key >= '1' && e.key <= '9') {
36
+ const idx = Number(e.key) - 1;
37
+ if (idx >= 0 && idx < request.options.length) {
38
+ onAnswer(idx);
39
+ }
40
+ }
41
+ };
42
+
43
+ const handleOptionClick = (index: number) => {
44
+ onAnswer(index);
45
+ };
46
+
47
+ const handleSubmitCustom = (e: React.FormEvent) => {
48
+ e.preventDefault();
49
+ if (customText.trim()) {
50
+ onAnswer(0, customText);
51
+ }
52
+ };
53
+
54
+ return (
55
+ <div className="panel question-panel">
56
+ <div className="panel-header">
57
+ <strong>Question</strong>
58
+ </div>
59
+ <div className="panel-content">
60
+ <div className="question-prompt">
61
+ {request.prompt.split('\n').map((line, i) => (
62
+ <div key={i}>{line}</div>
63
+ ))}
64
+ </div>
65
+ <div className="question-options">
66
+ {request.options.map((option, index) => (
67
+ <div
68
+ key={index}
69
+ className={`question-option ${index === selectedIndex ? 'selected' : ''}`}
70
+ onClick={() => handleOptionClick(index)}
71
+ >
72
+ <span className="option-key">{index + 1}.</span>
73
+ <span className="option-label">{option.label}</span>
74
+ </div>
75
+ ))}
76
+ </div>
77
+ <div className="custom-input-container">
78
+ <form onSubmit={handleSubmitCustom}>
79
+ <input
80
+ ref={inputRef}
81
+ type="text"
82
+ value={customText}
83
+ onChange={(e) => setCustomText(e.target.value)}
84
+ onKeyDown={handleKeyDown}
85
+ placeholder="Tell Mosaic what it should do..."
86
+ className="panel-input"
87
+ />
88
+ </form>
89
+ </div>
90
+ </div>
91
+ <style>{`
92
+ .panel {
93
+ background: var(--bg-panel);
94
+ border: 1px solid var(--border-subtle);
95
+ border-radius: 8px;
96
+ margin: 1rem 0;
97
+ padding: 1rem;
98
+ }
99
+ .panel-header {
100
+ margin-bottom: 1rem;
101
+ color: var(--accent-color);
102
+ }
103
+ .question-prompt {
104
+ margin-bottom: 1rem;
105
+ font-weight: 500;
106
+ line-height: 1.5;
107
+ }
108
+ .question-options {
109
+ display: flex;
110
+ flex-direction: column;
111
+ gap: 0.5rem;
112
+ margin-bottom: 1rem;
113
+ }
114
+ .question-option {
115
+ padding: 0.5rem;
116
+ border-radius: 4px;
117
+ cursor: pointer;
118
+ display: flex;
119
+ gap: 0.5rem;
120
+ transition: background 0.1s;
121
+ }
122
+ .question-option:hover {
123
+ background: var(--overlay-light);
124
+ }
125
+ .question-option.selected {
126
+ background: var(--overlay-medium);
127
+ border: 1px solid var(--border-subtle);
128
+ }
129
+ .option-key {
130
+ color: var(--text-muted);
131
+ width: 1.5rem;
132
+ }
133
+ .panel-input {
134
+ width: 100%;
135
+ padding: 0.75rem;
136
+ background: var(--bg-app);
137
+ border: 1px solid var(--border-subtle);
138
+ color: var(--text-primary);
139
+ border-radius: 6px;
140
+ font-family: inherit;
141
+ }
142
+ .panel-input:focus {
143
+ outline: none;
144
+ border-color: var(--accent-color);
145
+ }
146
+ `}</style>
147
+ </div>
148
+ );
149
+ }
@@ -0,0 +1,211 @@
1
+ /** @jsxImportSource react */
2
+ import React, { useState, useEffect, useRef } from 'react';
3
+ import '../assets/css/Setup.css';
4
+
5
+ interface TriggerConfig {
6
+ provider: string;
7
+ model: string;
8
+ apiKey?: string;
9
+ }
10
+
11
+ interface SetupProps {
12
+ onComplete: (config: TriggerConfig) => void;
13
+ }
14
+
15
+ interface Provider {
16
+ id: string;
17
+ name: string;
18
+ description: string;
19
+ requiresApiKey: boolean;
20
+ models: Model[];
21
+ isCustom?: boolean;
22
+ }
23
+
24
+ interface Model {
25
+ id: string;
26
+ name: string;
27
+ description: string;
28
+ requiresApiKey?: boolean;
29
+ }
30
+
31
+ type Step = 'provider' | 'model' | 'apikey' | 'confirm';
32
+
33
+ export function Setup({ onComplete }: SetupProps) {
34
+ const [step, setStep] = useState<Step>('provider');
35
+ const [providers, setProviders] = useState<Provider[]>([]);
36
+ const [selectedProviderId, setSelectedProviderId] = useState<string>('');
37
+ const [selectedModelId, setSelectedModelId] = useState<string>('');
38
+ const [apiKey, setApiKey] = useState<string>('');
39
+ const [selectedIndex, setSelectedIndex] = useState(0);
40
+
41
+ useEffect(() => {
42
+ fetch('/api/config/providers')
43
+ .then(res => res.json())
44
+ .then(data => setProviders(data))
45
+ .catch(err => console.error("Failed to load providers", err));
46
+ }, []);
47
+
48
+ useEffect(() => {
49
+ setSelectedIndex(0);
50
+ }, [step]);
51
+
52
+ const handleKeyDown = (e: React.KeyboardEvent) => {
53
+ if (step === 'provider') {
54
+ const options = providers;
55
+ if (e.key === 'ArrowDown') {
56
+ setSelectedIndex(prev => (prev + 1) % options.length);
57
+ } else if (e.key === 'ArrowUp') {
58
+ setSelectedIndex(prev => (prev - 1 + options.length) % options.length);
59
+ } else if (e.key === 'Enter') {
60
+ const provider = options[selectedIndex];
61
+ if (provider) {
62
+ setSelectedProviderId(provider.id);
63
+ setStep('model');
64
+ }
65
+ }
66
+ } else if (step === 'model') {
67
+ const provider = providers.find(p => p.id === selectedProviderId);
68
+ const options = provider?.models || [];
69
+ if (e.key === 'ArrowDown') {
70
+ setSelectedIndex(prev => (prev + 1) % options.length);
71
+ } else if (e.key === 'ArrowUp') {
72
+ setSelectedIndex(prev => (prev - 1 + options.length) % options.length);
73
+ } else if (e.key === 'Enter') {
74
+ const model = options[selectedIndex];
75
+ if (model) {
76
+ setSelectedModelId(model.id);
77
+ const requiresKey = (provider?.requiresApiKey || model.requiresApiKey);
78
+ if (requiresKey && provider?.id !== 'ollama') {
79
+ setStep('apikey');
80
+ } else if (requiresKey && provider?.id === 'ollama' && (model.id.includes(':cloud') || model.id.includes('-cloud'))) {
81
+ setStep('apikey');
82
+ } else {
83
+ setStep('confirm');
84
+ }
85
+ }
86
+ } else if (e.key === 'Escape') {
87
+ setStep('provider');
88
+ }
89
+ } else if (step === 'apikey') {
90
+ if (e.key === 'Enter') {
91
+ setStep('confirm');
92
+ } else if (e.key === 'Escape') {
93
+ setStep('model');
94
+ }
95
+ } else if (step === 'confirm') {
96
+ if (e.key === 'Enter') {
97
+ onComplete({
98
+ provider: selectedProviderId,
99
+ model: selectedModelId,
100
+ apiKey: apiKey || undefined
101
+ });
102
+ } else if (e.key === 'Escape') {
103
+ const provider = providers.find(p => p.id === selectedProviderId);
104
+ const model = provider?.models.find(m => m.id === selectedModelId);
105
+
106
+ if (provider && model) {
107
+ const requiresKey = (provider.requiresApiKey || model.requiresApiKey);
108
+ if (requiresKey) setStep('apikey');
109
+ else setStep('model');
110
+ } else {
111
+ setStep('model');
112
+ }
113
+ }
114
+ }
115
+ };
116
+
117
+ const containerRef = useRef<HTMLDivElement>(null);
118
+ useEffect(() => {
119
+ containerRef.current?.focus();
120
+ }, [step]);
121
+
122
+
123
+ const renderProviderStep = () => {
124
+ return (
125
+ <div className="step-container">
126
+ <div className="step-header">Select your AI provider (↑/↓ to navigate, Enter to select):</div>
127
+ <div className="select-list">
128
+ {providers.map((p, idx) => (
129
+ <div key={p.id} className={`select-item ${idx === selectedIndex ? 'selected' : ''}`}
130
+ onClick={() => { setSelectedIndex(idx); }}>
131
+ <span className="item-name">{p.name}</span>
132
+ {idx === selectedIndex && <span className="item-desc"> - {p.description}</span>}
133
+ </div>
134
+ ))}
135
+ </div>
136
+ </div>
137
+ );
138
+ };
139
+
140
+ const renderModelStep = () => {
141
+ const provider = providers.find(p => p.id === selectedProviderId);
142
+ const models = provider?.models || [];
143
+ return (
144
+ <div className="step-container">
145
+ <div className="step-header">Select the AI model (↑/↓ to navigate, Enter to select):</div>
146
+ <div className="select-list">
147
+ {models.map((m, idx) => (
148
+ <div key={m.id} className={`select-item ${idx === selectedIndex ? 'selected' : ''}`}
149
+ onClick={() => { setSelectedIndex(idx); }}>
150
+ <span className="item-name">{m.name}</span>
151
+ {idx === selectedIndex && <span className="item-desc"> - {m.description}</span>}
152
+ </div>
153
+ ))}
154
+ </div>
155
+ <div className="step-footer">Escape to go back</div>
156
+ </div>
157
+ );
158
+ };
159
+
160
+ const renderApiKeyStep = () => {
161
+ const provider = providers.find(p => p.id === selectedProviderId);
162
+ return (
163
+ <div className="step-container">
164
+ <div className="step-header">Enter your {provider?.name} API key:</div>
165
+ <input
166
+ type="password"
167
+ className="custom-input"
168
+ value={apiKey}
169
+ onChange={(e) => setApiKey(e.target.value)}
170
+ autoFocus
171
+ placeholder="sk-..."
172
+ onKeyDown={(e) => {
173
+ if (e.key === 'Enter') setStep('confirm');
174
+ if (e.key === 'Escape') setStep('model');
175
+ }}
176
+ />
177
+ <div className="step-footer">Press Enter when done, Escape to go back</div>
178
+ </div>
179
+ );
180
+ };
181
+
182
+ const renderConfirmStep = () => {
183
+ const provider = providers.find(p => p.id === selectedProviderId);
184
+ const model = provider?.models.find(m => m.id === selectedModelId);
185
+
186
+ return (
187
+ <div className="step-container center-content">
188
+ <div className="bold-text">Configuration Complete!</div>
189
+ <div className="summary-box">
190
+ <div>Provider: {provider?.name}</div>
191
+ <div>Model: {model?.name}</div>
192
+ {apiKey && <div>API Key: ********************</div>}
193
+ </div>
194
+ <div className="step-footer">Press Enter to continue, Escape to go back</div>
195
+ </div>
196
+ );
197
+ };
198
+
199
+ return (
200
+ <div className="setup-screen" tabIndex={0} onKeyDown={handleKeyDown} ref={containerRef}>
201
+ <div className="setup-content">
202
+ <div className="main-title">Ready to innovate ?</div>
203
+
204
+ {step === 'provider' && renderProviderStep()}
205
+ {step === 'model' && renderModelStep()}
206
+ {step === 'apikey' && renderApiKeyStep()}
207
+ {step === 'confirm' && renderConfirmStep()}
208
+ </div>
209
+ </div>
210
+ );
211
+ }