@denizokcu/haze 0.0.1 → 0.0.3

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 (73) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +169 -70
  3. package/dist/cli/commands/chat.d.ts +4 -1
  4. package/dist/cli/commands/chat.js +606 -24
  5. package/dist/cli/commands/commands.d.ts +5 -0
  6. package/dist/cli/commands/commands.js +220 -11
  7. package/dist/cli/commands/formatters.d.ts +1 -0
  8. package/dist/cli/commands/formatters.js +23 -3
  9. package/dist/cli/commands/skills.d.ts +1 -1
  10. package/dist/cli/commands/skills.js +8 -5
  11. package/dist/cli/commands/streaming.d.ts +7 -1
  12. package/dist/cli/commands/streaming.js +533 -41
  13. package/dist/cli/index.js +5 -12
  14. package/dist/config/inputHistory.js +8 -0
  15. package/dist/config/paths.d.ts +0 -1
  16. package/dist/config/paths.js +0 -1
  17. package/dist/config/providers.d.ts +26 -0
  18. package/dist/config/providers.js +88 -0
  19. package/dist/config/settings.d.ts +9 -2
  20. package/dist/core/agent/compaction.d.ts +13 -0
  21. package/dist/core/agent/compaction.js +34 -0
  22. package/dist/core/agent/errors.d.ts +3 -0
  23. package/dist/core/agent/errors.js +13 -0
  24. package/dist/core/agent/events.d.ts +58 -0
  25. package/dist/core/agent/events.js +3 -0
  26. package/dist/core/goal/completionPolicy.d.ts +27 -0
  27. package/dist/core/goal/completionPolicy.js +67 -0
  28. package/dist/core/goal/requestClassifier.d.ts +6 -0
  29. package/dist/core/goal/requestClassifier.js +31 -0
  30. package/dist/core/goal/sessionGoal.d.ts +30 -0
  31. package/dist/core/goal/sessionGoal.js +88 -0
  32. package/dist/core/session/sessionStore.d.ts +37 -0
  33. package/dist/core/session/sessionStore.js +59 -0
  34. package/dist/llm/client.d.ts +1 -1
  35. package/dist/llm/client.js +6 -6
  36. package/dist/llm/hazeTools.d.ts +70 -0
  37. package/dist/llm/hazeTools.js +311 -97
  38. package/dist/llm/initPrompt.js +7 -5
  39. package/dist/llm/systemPrompt.js +25 -11
  40. package/dist/skills/SkillLoader.d.ts +12 -2
  41. package/dist/skills/SkillLoader.js +64 -18
  42. package/dist/skills/SkillRegistry.d.ts +1 -5
  43. package/dist/skills/SkillRegistry.js +10 -21
  44. package/dist/skills/builder/SkillBuilder.d.ts +31 -1
  45. package/dist/skills/builder/SkillBuilder.js +291 -20
  46. package/dist/skills/skillTools.d.ts +20 -0
  47. package/dist/skills/skillTools.js +25 -0
  48. package/dist/skills/types.d.ts +12 -51
  49. package/dist/ui/components/ErrorView.d.ts +2 -1
  50. package/dist/ui/components/Header.d.ts +4 -2
  51. package/dist/ui/components/Header.js +2 -2
  52. package/dist/ui/components/MarkdownText.d.ts +2 -1
  53. package/dist/ui/components/TextInput.d.ts +13 -2
  54. package/dist/ui/components/TextInput.js +125 -25
  55. package/dist/ui/theme.d.ts +2 -0
  56. package/dist/ui/theme.js +3 -1
  57. package/dist/utils/fs.d.ts +1 -0
  58. package/dist/utils/fs.js +10 -6
  59. package/examples/skills/files/SKILL.md +16 -0
  60. package/examples/skills/files/examples/file-editing.md +3 -0
  61. package/package.json +9 -9
  62. package/dist/skills/installer/SkillInstaller.d.ts +0 -1
  63. package/dist/skills/installer/SkillInstaller.js +0 -48
  64. package/dist/skills/manifestSchema.d.ts +0 -31
  65. package/dist/skills/manifestSchema.js +0 -23
  66. package/dist/tools/ToolExecutor.d.ts +0 -3
  67. package/dist/tools/ToolExecutor.js +0 -15
  68. package/dist/tools/types.d.ts +0 -9
  69. package/dist/tools/types.js +0 -1
  70. package/examples/skills/files/prompts/file_tasks.md +0 -1
  71. package/examples/skills/files/skill.yaml +0 -28
  72. package/examples/skills/files/tools/list_files.ts +0 -21
  73. package/examples/skills/files/tools/read_file.ts +0 -12
@@ -1,101 +1,683 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from 'react';
3
- import { Box, render, Text, useApp, useStdout } from 'ink';
3
+ import { execFile as execFileCallback } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { Box, render, Static, Text, useApp, useStdout } from 'ink';
4
6
  import Spinner from 'ink-spinner';
5
7
  import { readContextFiles } from '../../config/contextFiles.js';
6
8
  import { addInputHistoryItem, readInputHistory } from '../../config/inputHistory.js';
7
9
  import { readSettings, updateSettings } from '../../config/settings.js';
10
+ import { activeModel, configuredProviders, DEFAULT_PROVIDER_NAME, findProvider, modelSelector, providerHasKey, resolveModelSelector, upsertProvider } from '../../config/providers.js';
8
11
  import { Header } from '../../ui/components/Header.js';
9
12
  import { TextInput } from '../../ui/components/TextInput.js';
10
13
  import { MarkdownText } from '../../ui/components/MarkdownText.js';
11
14
  import { theme } from '../../ui/theme.js';
12
15
  import { handleSlashCommand } from './commands.js';
13
16
  import { runAgentTurn } from './streaming.js';
14
- function ChatScreen({ debug = false }) {
17
+ import { loadSkillRegistry } from '../../skills/SkillRegistry.js';
18
+ import { appendSessionEntry, createSession, formatSession, latestSession, restoreConversation } from '../../core/session/sessionStore.js';
19
+ import { compactModelMessages, modelMessageText } from '../../core/agent/compaction.js';
20
+ import { createSessionGoal, formatGoalStatus } from '../../core/goal/sessionGoal.js';
21
+ const execFile = promisify(execFileCallback);
22
+ async function currentBranchName() {
23
+ try {
24
+ const { stdout } = await execFile('git', ['branch', '--show-current'], { cwd: process.cwd() });
25
+ return stdout.trim() || undefined;
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ }
31
+ function toolCallCount(messages) {
32
+ return messages.reduce((total, message) => {
33
+ if (message.role !== 'tool')
34
+ return total;
35
+ const headerCount = /Tools: (\d+) calls?/.exec(message.text)?.[1];
36
+ if (headerCount)
37
+ return total + Number(headerCount);
38
+ const rows = message.text.split('\n').filter(line => /^\s+[✓✗…]\s/.test(line));
39
+ return total + rows.reduce((rowTotal, row) => rowTotal + Number(/×(\d+)/.exec(row)?.[1] ?? 1), 0);
40
+ }, 0);
41
+ }
42
+ function estimateTokens(text) {
43
+ return Math.ceil(text.length / 4);
44
+ }
45
+ function formatTokenCount(tokens) {
46
+ if (tokens >= 1_000_000)
47
+ return `${(tokens / 1_000_000).toFixed(tokens >= 10_000_000 ? 0 : 1).replace(/\.0$/, '')}m`;
48
+ if (tokens >= 1_000)
49
+ return `${(tokens / 1_000).toFixed(tokens >= 10_000 ? 0 : 1).replace(/\.0$/, '')}k`;
50
+ return String(tokens);
51
+ }
52
+ function truncateWithEllipsis(text, maxLength) {
53
+ if (text.length <= maxLength)
54
+ return text;
55
+ return `${text.slice(0, maxLength).trimEnd()}…`;
56
+ }
57
+ function displayMessagesFromConversation(conversation) {
58
+ return conversation.flatMap(message => {
59
+ if (message.role !== 'user' && message.role !== 'assistant')
60
+ return [];
61
+ const text = modelMessageText(message).trim();
62
+ return text ? [{ role: message.role, text }] : [];
63
+ });
64
+ }
65
+ function estimateConversationTokens(messages) {
66
+ const inputText = messages
67
+ .filter(message => message.role === 'user' || message.role === 'tool')
68
+ .map(message => message.text)
69
+ .join('\n');
70
+ const outputText = messages
71
+ .filter(message => message.role === 'assistant')
72
+ .map(message => message.text)
73
+ .join('\n');
74
+ return {
75
+ input: estimateTokens(inputText),
76
+ output: estimateTokens(outputText),
77
+ };
78
+ }
79
+ function fullWidthLines(text, width, leftPadding = 0) {
80
+ const safeWidth = Math.max(1, width);
81
+ const prefix = ' '.repeat(leftPadding);
82
+ return text.replace(/\r\n|\r/g, '\n').split('\n').map(line => `${prefix}${line}`.padEnd(Math.max(safeWidth, line.length + leftPadding)));
83
+ }
84
+ function fullWidthBlankLine(width) {
85
+ return ''.padEnd(Math.max(1, width));
86
+ }
87
+ function ToolMessageText({ text, streaming }) {
88
+ const lines = text.split('\n');
89
+ return _jsx(Box, { flexDirection: "column", children: lines.map((line, index) => {
90
+ const row = /^(\s*)([✓✗…])\s+(\S+)(.*)$/.exec(line);
91
+ if (!row) {
92
+ return _jsxs(Text, { color: theme.muted, children: [index === 0 && streaming ? _jsxs(_Fragment, { children: [_jsx(Spinner, { type: "dots" }), " "] }) : null, line] }, `${index}-${line}`);
93
+ }
94
+ const [, indent, icon, toolName, rest] = row;
95
+ const iconColor = icon === '✓' ? theme.success : icon === '✗' ? theme.danger : theme.muted;
96
+ return _jsxs(Text, { color: theme.muted, children: [indent, _jsx(Text, { color: iconColor, children: icon }), " ", _jsx(Text, { color: theme.purple, children: toolName }), rest] }, `${index}-${line}`);
97
+ }) });
98
+ }
99
+ function MessageView({ message, width }) {
100
+ if (message.role === 'user') {
101
+ return _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { backgroundColor: theme.quoteBg, children: fullWidthBlankLine(width) }), _jsx(Text, { color: theme.success, bold: true, backgroundColor: theme.quoteBg, children: ' You asked'.padEnd(width) }), fullWidthLines(message.text, width, 2).map((line, lineIndex) => _jsx(Text, { color: "white", backgroundColor: theme.quoteBg, children: line }, lineIndex)), _jsx(Text, { backgroundColor: theme.quoteBg, children: fullWidthBlankLine(width) })] });
102
+ }
103
+ return _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: message.role === 'assistant' ? theme.purple : message.role === 'tool' ? theme.blue : theme.muted, bold: true, children: message.role === 'assistant' ? 'haze' : message.role === 'tool' ? 'Tool' : 'Info' }), message.role === 'tool'
104
+ ? _jsx(ToolMessageText, { text: message.text, streaming: message.streaming })
105
+ : message.role === 'assistant' && !message.streaming
106
+ ? _jsx(MarkdownText, { content: message.text })
107
+ : _jsx(Text, { children: message.text })] });
108
+ }
109
+ function messageKey(message, index) {
110
+ return message.id ?? `${index}-${message.role}-${message.text}`;
111
+ }
112
+ function startupProviderInfo(settings) {
113
+ const selection = activeModel(settings);
114
+ const model = process.env.HAZE_MODEL ?? selection.model;
115
+ const modelSource = process.env.HAZE_MODEL ? 'HAZE_MODEL env' : settings.model ? 'settings' : 'provider default';
116
+ const baseURL = process.env.OPENAI_BASE_URL ?? selection.provider.url;
117
+ const baseURLSource = process.env.OPENAI_BASE_URL ? 'OPENAI_BASE_URL env' : 'settings';
118
+ const apiKeySource = process.env.OPENAI_API_KEY ? 'OPENAI_API_KEY env' : providerHasKey(settings, selection.provider) ? `provider ${selection.provider.name}` : 'missing';
119
+ const provider = process.env.OPENAI_BASE_URL ? 'OpenAI-compatible custom endpoint' : selection.provider.name;
120
+ return [
121
+ 'Provider configuration',
122
+ `- Provider: ${provider}`,
123
+ `- Model: ${model} (${modelSource})`,
124
+ `- Base URL: ${baseURL} (${baseURLSource})`,
125
+ `- API key: ${apiKeySource === 'missing' ? 'not configured; local providers may not need one' : `configured via ${apiKeySource}`}`,
126
+ `- Configured providers: ${configuredProviders(settings).length}`,
127
+ ].join('\n');
128
+ }
129
+ function ChatScreen({ debug = false, version, continueSession = false, noSession = false }) {
15
130
  const { exit } = useApp();
16
131
  const { stdout } = useStdout();
17
- const height = stdout.rows ?? process.stdout.rows ?? 24;
132
+ const width = stdout.columns ?? process.stdout.columns ?? 80;
18
133
  const [messages, setMessages] = useState([
19
- { role: 'system', text: 'Welcome to Haze. Use /login for OpenRouter, /model to choose a model, /help for commands.' }
134
+ { role: 'system', text: 'Welcome to Haze. Use /help for commands.' }
20
135
  ]);
21
136
  const [settings, setSettings] = useState({});
22
137
  const conversationRef = useRef([]);
23
138
  const lastAssistantTextRef = useRef('');
139
+ const abortControllerRef = useRef(null);
140
+ const sessionRef = useRef(undefined);
141
+ const followUpQueueRef = useRef([]);
24
142
  const [inputHistory, setInputHistory] = useState([]);
25
143
  const [debugLogs, setDebugLogs] = useState([]);
26
144
  const [contextFiles, setContextFiles] = useState([]);
27
145
  const [mode, setMode] = useState('chat');
28
146
  const [busy, setBusy] = useState(false);
147
+ const [busyLabel, setBusyLabel] = useState('Haze is thinking');
148
+ const [activeGoalStatus, setActiveGoalStatus] = useState();
149
+ const [sessionLabel, setSessionLabel] = useState();
150
+ const [queuedFollowUps, setQueuedFollowUps] = useState([]);
151
+ const [skills, setSkills] = useState([]);
152
+ const [branchName, setBranchName] = useState();
153
+ const [modelProviderFilter, setModelProviderFilter] = useState();
154
+ const [selectedProviderName, setSelectedProviderName] = useState();
155
+ const [providerDraft, setProviderDraft] = useState({});
29
156
  useEffect(() => {
30
- readSettings().then(setSettings).catch(() => undefined);
157
+ Promise.all([readSettings(), currentBranchName()]).then(([next, branch]) => {
158
+ setSettings(next);
159
+ setBranchName(branch);
160
+ setMessages(m => [...m, { role: 'system', text: startupProviderInfo(next) }]);
161
+ }).catch(() => {
162
+ currentBranchName().then(branch => {
163
+ setBranchName(branch);
164
+ setMessages(m => [...m, { role: 'system', text: startupProviderInfo({}) }]);
165
+ }).catch(() => {
166
+ setMessages(m => [...m, { role: 'system', text: startupProviderInfo({}) }]);
167
+ });
168
+ });
169
+ initializeSession().catch(error => {
170
+ const text = error instanceof Error ? error.message : String(error);
171
+ setMessages(m => [...m, { role: 'system', text: `Session disabled: ${text}` }]);
172
+ });
31
173
  readInputHistory().then(setInputHistory).catch(() => undefined);
32
174
  readContextFiles().then(setContextFiles).catch(() => undefined);
175
+ refreshSkills().catch(() => undefined);
176
+ const branchTimer = setInterval(() => {
177
+ currentBranchName().then(setBranchName).catch(() => setBranchName(undefined));
178
+ }, 3000);
179
+ return () => clearInterval(branchTimer);
33
180
  }, []);
34
181
  function persistInputHistory(value) {
35
182
  addInputHistoryItem(value).then(setInputHistory).catch(() => undefined);
36
183
  }
184
+ async function refreshSkills() {
185
+ const registry = await loadSkillRegistry();
186
+ const nextSkills = [...registry.skills.values()];
187
+ setSkills(nextSkills);
188
+ return nextSkills;
189
+ }
190
+ function skillInvocation(value) {
191
+ if (!value.startsWith('/'))
192
+ return undefined;
193
+ const [name, ...args] = value.slice(1).trim().split(/\s+/).filter(Boolean);
194
+ if (!name)
195
+ return undefined;
196
+ const skill = skills.find(candidate => candidate.name === name);
197
+ return skill ? { skill, args: args.join(' ') } : undefined;
198
+ }
37
199
  function debugLog(line) {
38
200
  if (!debug)
39
201
  return;
40
202
  setDebugLogs(current => [...current.slice(-7), line]);
41
203
  }
204
+ async function startNewSession(message = 'Started a new session.') {
205
+ if (noSession) {
206
+ sessionRef.current = undefined;
207
+ setSessionLabel('session off');
208
+ return;
209
+ }
210
+ const session = await createSession({ hazeVersion: version });
211
+ sessionRef.current = session;
212
+ setSessionLabel(session.id);
213
+ setMessages(m => [...m, { role: 'system', text: `${message}\nSession saved: ${session.file}` }]);
214
+ }
215
+ async function initializeSession() {
216
+ if (noSession) {
217
+ setSessionLabel('session off');
218
+ return;
219
+ }
220
+ if (continueSession) {
221
+ const session = await latestSession();
222
+ if (session) {
223
+ const conversation = await restoreConversation(session);
224
+ sessionRef.current = session;
225
+ conversationRef.current = conversation;
226
+ setSessionLabel(session.id);
227
+ setMessages(m => [...m, { role: 'system', text: `Resumed session: ${formatSession(session)}` }, ...displayMessagesFromConversation(conversation)]);
228
+ return;
229
+ }
230
+ }
231
+ await startNewSession(continueSession ? 'No previous session found. Started a new session.' : 'Started a new session.');
232
+ }
42
233
  function clearConversation() {
43
234
  conversationRef.current = [];
44
235
  lastAssistantTextRef.current = '';
45
236
  setMessages([{ role: 'system', text: 'Cleared. The void is productive.' }]);
237
+ const session = sessionRef.current;
238
+ if (session)
239
+ void appendSessionEntry(session, { type: 'event', at: new Date().toISOString(), name: 'clear', text: 'Conversation cleared' }).catch(() => undefined);
46
240
  }
47
- async function submit(value) {
48
- if (busy)
241
+ function compactConversation(instructions) {
242
+ const result = compactModelMessages(conversationRef.current, { instructions });
243
+ if (!result.compacted) {
244
+ setMessages(m => [...m, { role: 'system', text: `Compaction skipped: only ${result.keptCount} model messages in context.` }]);
245
+ return false;
246
+ }
247
+ conversationRef.current = result.messages;
248
+ const session = sessionRef.current;
249
+ if (session) {
250
+ void appendSessionEntry(session, { type: 'event', at: new Date().toISOString(), name: 'compact', text: `Compacted ${result.olderCount} messages; kept ${result.keptCount}.` }).catch(() => undefined);
251
+ void appendSessionEntry(session, { type: 'conversation_snapshot', at: new Date().toISOString(), messages: result.messages }).catch(() => undefined);
252
+ }
253
+ setMessages(m => [...m, { role: 'system', text: `Compacted context: summarized ${result.olderCount} older model messages and kept the last ${result.keptCount}.` }]);
254
+ return true;
255
+ }
256
+ async function resumeLatestSession() {
257
+ const session = await latestSession();
258
+ if (!session) {
259
+ setMessages(m => [...m, { role: 'system', text: 'No previous session found for this workspace.' }]);
49
260
  return;
50
- if (mode === 'apiKey') {
51
- const next = await updateSettings({ provider: 'openrouter', apiKey: value, baseURL: 'https://openrouter.ai/api/v1' });
52
- setSettings(next);
261
+ }
262
+ const conversation = await restoreConversation(session);
263
+ sessionRef.current = session;
264
+ conversationRef.current = conversation;
265
+ setSessionLabel(session.id);
266
+ setMessages([{ role: 'system', text: `Resumed session: ${formatSession(session)}` }, ...displayMessagesFromConversation(conversation)]);
267
+ }
268
+ function cancelThinking() {
269
+ if (!busy)
270
+ return;
271
+ abortControllerRef.current?.abort('User pressed Esc.');
272
+ if (followUpQueueRef.current.length > 0) {
273
+ followUpQueueRef.current = [];
274
+ setQueuedFollowUps([]);
275
+ setMessages(m => [...m, { role: 'system', text: 'Cleared queued follow-ups after interrupt.' }]);
276
+ }
277
+ setBusy(false);
278
+ }
279
+ function queueFollowUp(value) {
280
+ const trimmed = value.trim();
281
+ if (!trimmed)
282
+ return;
283
+ followUpQueueRef.current = [...followUpQueueRef.current, trimmed];
284
+ setQueuedFollowUps(followUpQueueRef.current);
285
+ setMessages(m => [...m, { role: 'system', text: `Queued follow-up (${followUpQueueRef.current.length}): ${trimmed}` }]);
286
+ }
287
+ function closeInputList() {
288
+ if (mode === 'provider' || mode === 'providerAction' || mode === 'model') {
53
289
  setMode('chat');
54
- setMessages(m => [...m, { role: 'system', text: 'OpenRouter login saved to ~/.haze/settings.json. Security theatre completed.' }]);
290
+ setModelProviderFilter(undefined);
291
+ setSelectedProviderName(undefined);
292
+ }
293
+ }
294
+ function providerSuggestions() {
295
+ return [
296
+ ...configuredProviders(settings).map(provider => ({
297
+ value: provider.name,
298
+ description: `${provider.url} · ${provider.models.length} model${provider.models.length === 1 ? '' : 's'}`,
299
+ kind: 'provider',
300
+ })),
301
+ { value: 'add provider', description: 'Add a new OpenAI-compatible provider', kind: 'provider' },
302
+ ];
303
+ }
304
+ function providerActionSuggestions() {
305
+ return [
306
+ { value: 'use provider', description: 'Set this provider and choose a model', kind: 'provider' },
307
+ { value: 'add models', description: 'Append comma-separated model names to this provider', kind: 'provider' },
308
+ ];
309
+ }
310
+ function modelSuggestions() {
311
+ const providers = configuredProviders(settings).filter(provider => !modelProviderFilter || provider.name === modelProviderFilter);
312
+ return providers.flatMap(provider => provider.models.map(model => ({
313
+ value: modelProviderFilter ? model : modelSelector(provider, model),
314
+ description: provider.name,
315
+ kind: 'model',
316
+ })));
317
+ }
318
+ async function selectProvider(providerName) {
319
+ if (providerName === 'add provider') {
320
+ setProviderDraft({});
321
+ setMode('providerAddName');
322
+ setMessages(m => [...m, { role: 'system', text: 'Provider name? Example: openrouter, local, lmstudio.' }]);
55
323
  return;
56
324
  }
57
- if (mode === 'model') {
58
- const next = await updateSettings({ model: value });
59
- setSettings(next);
325
+ const provider = findProvider(settings, providerName);
326
+ if (!provider) {
327
+ setMessages(m => [...m, { role: 'system', text: `No provider named ${providerName}. Use /provider and choose add provider.` }]);
60
328
  setMode('chat');
61
- setMessages(m => [...m, { role: 'system', text: `Model set to ${value}.` }]);
62
329
  return;
63
330
  }
331
+ setSelectedProviderName(provider.name);
332
+ setMode('providerAction');
333
+ setMessages(m => [...m, { role: 'system', text: `${provider.name}: choose an action.` }]);
334
+ }
335
+ async function useProvider(providerName) {
336
+ const provider = findProvider(settings, providerName);
337
+ if (!provider) {
338
+ setMessages(m => [...m, { role: 'system', text: `No provider named ${providerName}.` }]);
339
+ setMode('chat');
340
+ setSelectedProviderName(undefined);
341
+ return;
342
+ }
343
+ const next = await updateSettings({ provider: provider.name });
344
+ setSettings(next);
345
+ setSelectedProviderName(undefined);
346
+ setModelProviderFilter(provider.name);
347
+ setMode('model');
348
+ setMessages(m => [...m, { role: 'system', text: `Provider set to ${provider.name}. Choose a model.` }]);
349
+ }
350
+ async function selectProviderAction(action) {
351
+ if (!selectedProviderName) {
352
+ setMode('provider');
353
+ return;
354
+ }
355
+ if (action === 'use provider') {
356
+ await useProvider(selectedProviderName);
357
+ return;
358
+ }
359
+ if (action === 'add models') {
360
+ setMode('providerAppendModels');
361
+ setMessages(m => [...m, { role: 'system', text: `Comma-separated model names to add to ${selectedProviderName}?` }]);
362
+ return;
363
+ }
364
+ setMessages(m => [...m, { role: 'system', text: `Unknown provider action: ${action}` }]);
365
+ }
366
+ async function selectModel(selector) {
367
+ const scopedSelector = modelProviderFilter ? `${modelProviderFilter}:${selector}` : selector;
368
+ const resolved = resolveModelSelector(settings, scopedSelector);
369
+ if (resolved.status === 'ambiguous') {
370
+ setMessages(m => [...m, { role: 'system', text: `Model ${resolved.model} exists on multiple providers: ${resolved.providers.map(provider => modelSelector(provider, resolved.model)).join(', ')}` }]);
371
+ return;
372
+ }
373
+ if (resolved.status === 'missing') {
374
+ setMessages(m => [...m, { role: 'system', text: `No configured model named ${selector}. Use /provider, select a provider, then choose add models.` }]);
375
+ return;
376
+ }
377
+ const next = await updateSettings({ provider: resolved.provider.name, model: resolved.model });
378
+ setSettings(next);
379
+ setModelProviderFilter(undefined);
380
+ setMode('chat');
381
+ setMessages(m => [...m, { role: 'system', text: `Model set to ${resolved.model} on ${resolved.provider.name}.\n\n${startupProviderInfo(next)}` }]);
382
+ }
383
+ async function appendModelsToProvider(modelsValue) {
384
+ const provider = selectedProviderName ? findProvider(settings, selectedProviderName) : undefined;
385
+ const models = modelsValue.split(',').map(model => model.trim()).filter(Boolean);
386
+ if (!provider) {
387
+ setMessages(m => [...m, { role: 'system', text: 'No provider selected.' }]);
388
+ setMode('chat');
389
+ return;
390
+ }
391
+ if (models.length === 0) {
392
+ setMessages(m => [...m, { role: 'system', text: 'Enter at least one model name.' }]);
393
+ return;
394
+ }
395
+ const nextProvider = { ...provider, models: [...new Set([...provider.models, ...models])] };
396
+ const next = await updateSettings({ providers: upsertProvider(settings, nextProvider), provider: provider.name });
397
+ setSettings(next);
398
+ setSelectedProviderName(undefined);
399
+ setModelProviderFilter(provider.name);
400
+ setMode('model');
401
+ setMessages(m => [...m, { role: 'system', text: `Added ${models.length} model${models.length === 1 ? '' : 's'} to ${provider.name}. Choose a model.` }]);
402
+ }
403
+ async function finishProviderAdd(modelsValue) {
404
+ const models = modelsValue.split(',').map(model => model.trim()).filter(Boolean);
405
+ if (!providerDraft.name || !providerDraft.url || models.length === 0) {
406
+ setMessages(m => [...m, { role: 'system', text: 'Provider name, URL, and at least one model are required.' }]);
407
+ setMode('chat');
408
+ setProviderDraft({});
409
+ return;
410
+ }
411
+ const provider = {
412
+ name: providerDraft.name,
413
+ url: providerDraft.url,
414
+ ...(providerDraft.key ? { key: providerDraft.key } : {}),
415
+ models: [...new Set(models)],
416
+ };
417
+ const next = await updateSettings({ providers: upsertProvider(settings, provider), provider: provider.name });
418
+ setSettings(next);
419
+ setProviderDraft({});
420
+ setModelProviderFilter(provider.name);
421
+ setMode('model');
422
+ setMessages(m => [...m, { role: 'system', text: `Added provider ${provider.name}. Choose a model.` }]);
423
+ }
424
+ async function submit(value) {
425
+ if (busy) {
426
+ if (mode === 'chat')
427
+ queueFollowUp(value);
428
+ return;
429
+ }
430
+ if (mode === 'provider') {
431
+ await selectProvider(value);
432
+ return;
433
+ }
434
+ if (mode === 'providerAction') {
435
+ await selectProviderAction(value);
436
+ return;
437
+ }
438
+ if (mode === 'model') {
439
+ await selectModel(value);
440
+ return;
441
+ }
442
+ if (mode === 'providerAddName') {
443
+ const name = value.trim();
444
+ if (!name) {
445
+ setMessages(m => [...m, { role: 'system', text: 'Provider name is required.' }]);
446
+ return;
447
+ }
448
+ if (settings.providers?.some(provider => provider.name === name)) {
449
+ setMessages(m => [...m, { role: 'system', text: `Provider ${name} already exists. Choose a unique name.` }]);
450
+ return;
451
+ }
452
+ setProviderDraft({ name });
453
+ setMode('providerAddUrl');
454
+ setMessages(m => [...m, { role: 'system', text: 'OpenAI-compatible base URL? Example: https://openrouter.ai/api/v1 or http://localhost:1234/v1' }]);
455
+ return;
456
+ }
457
+ if (mode === 'providerAddUrl') {
458
+ try {
459
+ new URL(value);
460
+ }
461
+ catch {
462
+ setMessages(m => [...m, { role: 'system', text: 'Enter a valid URL, for example http://localhost:1234/v1.' }]);
463
+ return;
464
+ }
465
+ setProviderDraft(draft => ({ ...draft, url: value.trim() }));
466
+ setMode('providerAddKey');
467
+ setMessages(m => [...m, { role: 'system', text: 'API key? Leave blank for local/keyless providers.' }]);
468
+ return;
469
+ }
470
+ if (mode === 'providerAddKey') {
471
+ setProviderDraft(draft => ({ ...draft, ...(value.trim() ? { key: value.trim() } : {}) }));
472
+ setMode('providerAddModels');
473
+ setMessages(m => [...m, { role: 'system', text: 'Comma-separated model names? Example: llama3.1, qwen2.5-coder, gpt-4o' }]);
474
+ return;
475
+ }
476
+ if (mode === 'providerAddModels') {
477
+ await finishProviderAdd(value);
478
+ return;
479
+ }
480
+ if (mode === 'providerAppendModels') {
481
+ await appendModelsToProvider(value);
482
+ return;
483
+ }
484
+ const invokedSkill = skillInvocation(value);
485
+ if (invokedSkill) {
486
+ const argumentText = invokedSkill.args ? `\nUser-provided skill arguments: ${invokedSkill.args}` : '';
487
+ await doAgentTurn(`The user explicitly invoked the "${invokedSkill.skill.name}" skill. Call skill_${invokedSkill.skill.name.replace(/[^a-zA-Z0-9_]/g, '_')} and follow its returned instructions.${argumentText}`, value);
488
+ return;
489
+ }
490
+ const isSkillCreate = /^\/create-skill(?:\s|$)/.test(value) || /^\/skills? create(?:\s|$)/.test(value);
491
+ const skillCreateGoal = isSkillCreate ? createSessionGoal(value) : undefined;
492
+ if (skillCreateGoal) {
493
+ setDebugLogs([]);
494
+ setMessages(m => [...m, { role: 'user', text: value }]);
495
+ const session = sessionRef.current;
496
+ if (session)
497
+ void appendSessionEntry(session, { type: 'ui_message', at: new Date().toISOString(), role: 'user', text: value }).catch(() => undefined);
498
+ setActiveGoalStatus(formatGoalStatus(skillCreateGoal));
499
+ }
64
500
  const ctx = {
65
501
  settings,
66
502
  contextFiles,
67
503
  setMode,
504
+ setModelProviderFilter,
68
505
  addSystemMessage: text => setMessages(m => [...m, { role: 'system', text }]),
69
506
  clearConversation,
507
+ newSession: async () => {
508
+ conversationRef.current = [];
509
+ lastAssistantTextRef.current = '';
510
+ setMessages([{ role: 'system', text: 'Started fresh. The fog parts.' }]);
511
+ await startNewSession('Started a new session.');
512
+ },
513
+ resumeSession: resumeLatestSession,
514
+ sessionInfo: () => sessionRef.current ? formatSession(sessionRef.current) : 'Session persistence is off.',
515
+ compactConversation,
70
516
  runAgentTurn: (prompt, displayValue) => doAgentTurn(prompt, displayValue),
71
517
  refreshContextFiles: async () => { const files = await readContextFiles().catch(() => contextFiles); setContextFiles(files); return files; },
72
- updateSettings: patch => { const next = { ...settings, ...patch }; setSettings(next); return Promise.resolve(next); },
518
+ updateSettings: async (patch) => {
519
+ const next = await updateSettings(patch);
520
+ setSettings(next);
521
+ return next;
522
+ },
73
523
  };
74
- const result = await handleSlashCommand(value, ctx);
524
+ let result;
525
+ if (isSkillCreate) {
526
+ setBusyLabel('Creating skill');
527
+ setBusy(true);
528
+ }
529
+ try {
530
+ result = await handleSlashCommand(value, ctx);
531
+ }
532
+ catch (error) {
533
+ if (skillCreateGoal) {
534
+ skillCreateGoal.status = 'blocked';
535
+ skillCreateGoal.blocker = error instanceof Error ? error.message : String(error);
536
+ setActiveGoalStatus(formatGoalStatus(skillCreateGoal));
537
+ }
538
+ const text = error instanceof Error ? error.message : String(error);
539
+ setMessages(m => [...m, { role: 'system', text: `Skill creation failed: ${text}` }]);
540
+ return;
541
+ }
542
+ finally {
543
+ if (isSkillCreate) {
544
+ if (skillCreateGoal?.status === 'active') {
545
+ skillCreateGoal.phase = 'done';
546
+ skillCreateGoal.status = 'complete';
547
+ setActiveGoalStatus(undefined);
548
+ }
549
+ setBusy(false);
550
+ setBusyLabel('Haze is thinking');
551
+ }
552
+ }
75
553
  if (result === 'exit')
76
554
  return exit();
77
- if (result === 'handled')
555
+ if (result === 'handled') {
556
+ if (value === '/create-skill' || value.startsWith('/create-skill ') || value === '/skill create' || value.startsWith('/skill create ') || value === '/skills create' || value.startsWith('/skills create ') || value.startsWith('/remove-skill ') || value.startsWith('/skill remove ') || value.startsWith('/skills remove ')) {
557
+ await refreshSkills().catch(() => undefined);
558
+ }
78
559
  return;
560
+ }
79
561
  await doAgentTurn(value);
80
562
  }
81
563
  async function doAgentTurn(value, displayValue) {
82
564
  setDebugLogs([]);
565
+ await runSingleAgentTurn(value, displayValue);
566
+ while (followUpQueueRef.current.length > 0) {
567
+ const next = followUpQueueRef.current[0];
568
+ followUpQueueRef.current = followUpQueueRef.current.slice(1);
569
+ setQueuedFollowUps(followUpQueueRef.current);
570
+ setMessages(m => [...m, { role: 'system', text: `Running queued follow-up: ${next}` }]);
571
+ await runSingleAgentTurn(next);
572
+ }
573
+ }
574
+ async function runSingleAgentTurn(value, displayValue) {
83
575
  await runAgentTurn(value, displayValue, contextFiles, {
84
- addMessage: msg => setMessages(m => [...m, msg]),
576
+ addMessage: msg => {
577
+ setMessages(m => [...m, msg]);
578
+ const session = sessionRef.current;
579
+ if (session)
580
+ void appendSessionEntry(session, { type: 'ui_message', at: new Date().toISOString(), role: msg.role, text: msg.text }).catch(() => undefined);
581
+ },
85
582
  updateMessage: (id, update) => setMessages(m => m.map(msg => msg.id === id ? { ...msg, ...update } : msg)),
86
- setConversation: msgs => { conversationRef.current = msgs; },
583
+ setConversation: msgs => {
584
+ conversationRef.current = msgs;
585
+ const session = sessionRef.current;
586
+ if (session)
587
+ void appendSessionEntry(session, { type: 'conversation_snapshot', at: new Date().toISOString(), messages: msgs }).catch(() => undefined);
588
+ },
87
589
  setBusy,
88
590
  debugLog,
89
591
  getConversation: () => conversationRef.current,
90
592
  getLastAssistantText: () => lastAssistantTextRef.current,
91
593
  setLastAssistantText: text => { lastAssistantTextRef.current = text; },
594
+ setAbortController: controller => { abortControllerRef.current = controller; },
595
+ setGoalStatus: setActiveGoalStatus,
596
+ compactConversation,
597
+ onEvent: event => {
598
+ const session = sessionRef.current;
599
+ if (session)
600
+ void appendSessionEntry(session, { type: 'event', at: event.at, name: event.type, text: JSON.stringify(event) }).catch(() => undefined);
601
+ },
92
602
  });
93
603
  }
94
- const visible = messages;
95
- const placeholder = mode === 'apiKey' ? 'OpenRouter API key...' : mode === 'model' ? 'openai/gpt-4o-mini' : busy ? 'Thinking, allegedly...' : 'Ask Haze to help build your app...';
96
- return _jsxs(Box, { flexDirection: "column", minHeight: height, children: [_jsx(Box, { flexShrink: 0, children: _jsx(Header, { subtitle: "AI agent CLI for building apps" }) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: visible.map((message, index) => _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: message.role === 'user' ? theme.purple : message.role === 'assistant' ? theme.success : message.role === 'tool' ? theme.muted : theme.muted, bold: true, children: message.role === 'user' ? 'You' : message.role === 'assistant' ? 'Haze' : message.role === 'tool' ? 'Tool' : 'Info' }), message.role === 'assistant' && !message.streaming ? _jsx(MarkdownText, { content: message.text }) : _jsx(Text, { color: message.role === 'tool' ? theme.muted : undefined, children: message.streaming && message.role === 'tool' ? _jsxs(_Fragment, { children: [_jsx(Spinner, { type: "dots" }), " ", message.text] }) : message.text })] }, index)) }), debug && debugLogs.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, marginBottom: 1, borderStyle: "round", borderColor: theme.muted, paddingX: 1, children: [_jsx(Text, { color: theme.muted, bold: true, children: "Debug" }), debugLogs.map((line, index) => _jsxs(Text, { color: theme.muted, children: ["\u2022 ", line] }, index))] }), busy && _jsx(Box, { flexShrink: 0, marginBottom: 1, children: _jsxs(Text, { color: theme.muted, children: [_jsx(Spinner, { type: "dots" }), " Haze is thinking..."] }) }), _jsx(Box, { borderStyle: "round", borderColor: theme.deepPurple, paddingX: 1, height: 3, flexShrink: 0, children: _jsx(Box, { flexGrow: 1, minWidth: 0, children: _jsx(TextInput, { placeholder: placeholder, disabled: busy, mask: mode === 'apiKey', historyItems: inputHistory, recordHistory: mode === 'chat', onHistoryAdd: persistInputHistory, onSubmit: submit }) }) })] });
604
+ const visible = messages.filter(message => !message.hidden);
605
+ const transcriptItems = visible
606
+ .map((message, index) => ({ key: messageKey(message, index), message }))
607
+ .filter(item => !item.message.streaming);
608
+ const liveMessages = visible.filter(message => message.streaming);
609
+ const activeSelection = activeModel(settings);
610
+ const placeholder = mode === 'provider'
611
+ ? 'Choose provider'
612
+ : mode === 'providerAction'
613
+ ? 'Choose provider action'
614
+ : mode === 'model'
615
+ ? 'Choose model'
616
+ : mode === 'providerAddName'
617
+ ? 'Provider name'
618
+ : mode === 'providerAddUrl'
619
+ ? 'https://example.com/v1'
620
+ : mode === 'providerAddKey'
621
+ ? 'API key, or blank for local'
622
+ : mode === 'providerAddModels' || mode === 'providerAppendModels'
623
+ ? 'model-a, model-b'
624
+ : busy ? 'Queue a follow-up, or Esc to interrupt' : 'Ask Haze to help build your app';
625
+ const activeModelName = `${activeSelection.provider.name}:${process.env.HAZE_MODEL ?? activeSelection.model}`;
626
+ const hasLogin = Boolean(process.env.OPENAI_API_KEY ?? settings.apiKey ?? activeSelection.provider.key) || activeSelection.provider.name !== DEFAULT_PROVIDER_NAME;
627
+ const hasChosenModel = Boolean(process.env.HAZE_MODEL ?? settings.model ?? activeSelection.model);
628
+ const headerSubtitle = hasLogin && hasChosenModel
629
+ ? [
630
+ 'A minimal LLM harness for growing your own workflows while you work.',
631
+ '',
632
+ 'Start with simple chat, then teach Haze your habits with skills:',
633
+ '/create-skill review my branch against main — tiny spell, useful goblin.',
634
+ '',
635
+ 'The most adaptive workflow is the one you shape as you go.',
636
+ '',
637
+ 'Guardrails are light: Haze lets the LLM work from the terminal almost like you,',
638
+ 'while trying to stay scoped to this project.',
639
+ ].join('\n')
640
+ : 'First things first: run /provider to choose or add a provider, then select a model.';
641
+ const workspaceLabel = `${process.cwd()}${branchName ? ` (${branchName})` : ''}`;
642
+ const toolsUsed = toolCallCount(messages);
643
+ const estimatedTokens = estimateConversationTokens(messages);
644
+ const statusDetailLabel = `${conversationRef.current.length} messages / ${toolsUsed} tool call${toolsUsed === 1 ? '' : 's'} / ↑ ~${formatTokenCount(estimatedTokens.input)} ↓ ~${formatTokenCount(estimatedTokens.output)} / ${skills.length} skill${skills.length === 1 ? '' : 's'}${sessionLabel ? ` / ${sessionLabel}` : ''}`;
645
+ const goalText = activeGoalStatus?.replace(/^Goal:\s*/, '');
646
+ const [rawGoalRequest, ...goalStatusParts] = goalText?.split(' · ') ?? [];
647
+ const goalRequest = truncateWithEllipsis(rawGoalRequest ?? '', 120);
648
+ const goalStatusText = goalStatusParts.join(' · ');
649
+ const inputSuggestions = mode === 'provider' ? providerSuggestions() : mode === 'providerAction' ? providerActionSuggestions() : mode === 'model' ? modelSuggestions() : mode === 'chat' ? [
650
+ { value: '/help', description: 'Show commands', kind: 'command' },
651
+ { value: '/provider', description: 'Choose a provider', kind: 'command' },
652
+ { value: '/model', description: 'Choose a model', kind: 'command' },
653
+ { value: '/settings', description: 'Show provider, model, API key, and context status', kind: 'command' },
654
+ { value: '/create-skill ', description: 'Create a Markdown skill', kind: 'command' },
655
+ { value: '/list-skills', description: 'List installed skills', kind: 'command' },
656
+ { value: '/skill-info ', description: 'Show details for a skill', kind: 'command' },
657
+ { value: '/validate-skill ', description: 'Validate a skill', kind: 'command' },
658
+ { value: '/remove-skill ', description: 'Remove a skill with --yes', kind: 'command' },
659
+ { value: '/init', description: 'Create or update AGENTS.md project instructions', kind: 'command' },
660
+ { value: '/session', description: 'Show current session path', kind: 'command' },
661
+ { value: '/resume', description: 'Resume latest session for this workspace', kind: 'command' },
662
+ { value: '/new', description: 'Start a new session', kind: 'command' },
663
+ { value: '/compact ', description: 'Summarize older context and keep recent messages', kind: 'command' },
664
+ { value: '/clear', description: 'Clear conversation history', kind: 'command' },
665
+ { value: '/exit', description: 'Exit Haze', kind: 'command' },
666
+ { value: '/quit', description: 'Exit Haze', kind: 'command' },
667
+ ...skills.map(skill => ({ value: `/${skill.name}`, description: skill.description, kind: 'skill' })),
668
+ ] : [];
669
+ const staticItems = [
670
+ { kind: 'header', key: `header-${activeModelName}-${hasLogin}-${hasChosenModel}`, subtitle: headerSubtitle },
671
+ ...transcriptItems.map(item => ({ kind: 'message', ...item })),
672
+ ];
673
+ return _jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: staticItems, children: item => item.kind === 'header'
674
+ ? _jsx(Header, { subtitle: item.subtitle, version: version }, item.key)
675
+ : _jsx(MessageView, { message: item.message, width: width }, item.key) }), liveMessages.length > 0 && _jsx(Box, { flexDirection: "column", flexShrink: 0, children: liveMessages.map((message, index) => _jsx(MessageView, { message: message, width: width }, messageKey(message, index))) }), debug && debugLogs.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, marginBottom: 1, borderStyle: "round", borderColor: theme.muted, paddingX: 1, children: [_jsx(Text, { color: theme.muted, bold: true, children: "Debug" }), debugLogs.map((line, index) => _jsxs(Text, { color: theme.muted, children: ["\u2022 ", line] }, index))] }), queuedFollowUps.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, marginBottom: 1, children: [_jsx(Text, { color: theme.muted, children: "Queued follow-ups:" }), queuedFollowUps.map((item, index) => _jsxs(Text, { color: theme.muted, dimColor: true, children: [" ", index + 1, ". ", item] }, `${index}-${item}`))] }), busy && _jsx(Box, { flexShrink: 0, marginBottom: 1, children: _jsxs(Text, { children: [_jsxs(Text, { color: theme.orange, bold: true, children: [_jsx(Spinner, { type: "dots" }), " ", busyLabel] }), _jsx(Text, { color: theme.muted, dimColor: true, children: " \u00B7 type to queue follow-up \u00B7 esc to interrupt" })] }) }), goalText && _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: theme.blue, bold: true, children: "Goal:" }), _jsxs(Text, { color: "white", children: [" ", goalRequest] }), goalStatusText ? _jsxs(Text, { color: theme.orange, children: [" \u00B7 ", goalStatusText] }) : null] }) }), _jsx(Box, { borderStyle: "round", borderColor: theme.deepPurple, paddingX: 1, flexShrink: 0, children: _jsx(Box, { flexGrow: 1, minWidth: 0, children: _jsx(TextInput, { placeholder: placeholder, disabled: busy && mode !== 'chat', mask: mode === 'providerAddKey', historyItems: inputHistory, recordHistory: mode === 'chat', suggestions: inputSuggestions, suggestionMode: mode === 'provider' || mode === 'providerAction' || mode === 'model' ? 'always' : 'slash', submitOnEmpty: mode === 'providerAddKey', onHistoryAdd: persistInputHistory, onCancel: cancelThinking, onEscape: closeInputList, onSubmit: submit }) }) }), _jsxs(Box, { flexShrink: 0, justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "column", flexShrink: 1, minWidth: 0, children: [_jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: workspaceLabel }), _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: statusDetailLabel })] }), _jsx(Box, { flexShrink: 0, marginLeft: 2, children: _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-start", children: activeModelName }) })] })] });
97
676
  }
98
677
  export async function chatCommand(options = {}) {
99
- const app = render(_jsx(ChatScreen, { debug: options.debug }));
678
+ if (process.stdout.isTTY) {
679
+ process.stdout.write('\u001B[2J\u001B[3J\u001B[H');
680
+ }
681
+ const app = render(_jsx(ChatScreen, { debug: options.debug, version: options.version, continueSession: options.continueSession, noSession: options.noSession }));
100
682
  await app.waitUntilExit();
101
683
  }