@denizokcu/haze 0.0.2 → 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 (45) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +87 -33
  3. package/dist/cli/commands/chat.d.ts +3 -1
  4. package/dist/cli/commands/chat.js +442 -52
  5. package/dist/cli/commands/commands.d.ts +5 -0
  6. package/dist/cli/commands/commands.js +114 -29
  7. package/dist/cli/commands/formatters.js +5 -2
  8. package/dist/cli/commands/streaming.d.ts +5 -1
  9. package/dist/cli/commands/streaming.js +193 -86
  10. package/dist/cli/index.js +5 -2
  11. package/dist/config/inputHistory.js +8 -0
  12. package/dist/config/providers.d.ts +26 -0
  13. package/dist/config/providers.js +88 -0
  14. package/dist/config/settings.d.ts +9 -2
  15. package/dist/core/agent/compaction.d.ts +13 -0
  16. package/dist/core/agent/compaction.js +34 -0
  17. package/dist/core/agent/errors.d.ts +3 -0
  18. package/dist/core/agent/errors.js +13 -0
  19. package/dist/core/agent/events.d.ts +58 -0
  20. package/dist/core/agent/events.js +3 -0
  21. package/dist/core/goal/completionPolicy.d.ts +27 -0
  22. package/dist/core/goal/completionPolicy.js +67 -0
  23. package/dist/core/goal/requestClassifier.d.ts +6 -0
  24. package/dist/core/goal/requestClassifier.js +31 -0
  25. package/dist/core/goal/sessionGoal.d.ts +30 -0
  26. package/dist/core/goal/sessionGoal.js +88 -0
  27. package/dist/core/session/sessionStore.d.ts +37 -0
  28. package/dist/core/session/sessionStore.js +59 -0
  29. package/dist/llm/client.d.ts +1 -1
  30. package/dist/llm/client.js +6 -6
  31. package/dist/llm/hazeTools.d.ts +38 -0
  32. package/dist/llm/hazeTools.js +196 -92
  33. package/dist/llm/initPrompt.js +6 -4
  34. package/dist/llm/systemPrompt.js +3 -3
  35. package/dist/skills/builder/SkillBuilder.d.ts +6 -0
  36. package/dist/skills/builder/SkillBuilder.js +146 -24
  37. package/dist/ui/components/ErrorView.d.ts +2 -1
  38. package/dist/ui/components/Header.d.ts +2 -1
  39. package/dist/ui/components/Header.js +1 -11
  40. package/dist/ui/components/MarkdownText.d.ts +2 -1
  41. package/dist/ui/components/TextInput.d.ts +7 -3
  42. package/dist/ui/components/TextInput.js +112 -27
  43. package/dist/ui/theme.d.ts +1 -0
  44. package/dist/ui/theme.js +2 -1
  45. package/package.json +8 -8
@@ -2,11 +2,12 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
2
2
  import { useEffect, useRef, useState } from 'react';
3
3
  import { execFile as execFileCallback } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
- import { Box, render, Text, useApp, useStdout } from 'ink';
5
+ import { Box, render, Static, Text, useApp, useStdout } from 'ink';
6
6
  import Spinner from 'ink-spinner';
7
7
  import { readContextFiles } from '../../config/contextFiles.js';
8
8
  import { addInputHistoryItem, readInputHistory } from '../../config/inputHistory.js';
9
9
  import { readSettings, updateSettings } from '../../config/settings.js';
10
+ import { activeModel, configuredProviders, DEFAULT_PROVIDER_NAME, findProvider, modelSelector, providerHasKey, resolveModelSelector, upsertProvider } from '../../config/providers.js';
10
11
  import { Header } from '../../ui/components/Header.js';
11
12
  import { TextInput } from '../../ui/components/TextInput.js';
12
13
  import { MarkdownText } from '../../ui/components/MarkdownText.js';
@@ -14,6 +15,9 @@ import { theme } from '../../ui/theme.js';
14
15
  import { handleSlashCommand } from './commands.js';
15
16
  import { runAgentTurn } from './streaming.js';
16
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';
17
21
  const execFile = promisify(execFileCallback);
18
22
  async function currentBranchName() {
19
23
  try {
@@ -45,6 +49,19 @@ function formatTokenCount(tokens) {
45
49
  return `${(tokens / 1_000).toFixed(tokens >= 10_000 ? 0 : 1).replace(/\.0$/, '')}k`;
46
50
  return String(tokens);
47
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
+ }
48
65
  function estimateConversationTokens(messages) {
49
66
  const inputText = messages
50
67
  .filter(message => message.role === 'user' || message.role === 'tool')
@@ -59,6 +76,14 @@ function estimateConversationTokens(messages) {
59
76
  output: estimateTokens(outputText),
60
77
  };
61
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
+ }
62
87
  function ToolMessageText({ text, streaming }) {
63
88
  const lines = text.split('\n');
64
89
  return _jsx(Box, { flexDirection: "column", children: lines.map((line, index) => {
@@ -71,29 +96,40 @@ function ToolMessageText({ text, streaming }) {
71
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}`);
72
97
  }) });
73
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
+ }
74
112
  function startupProviderInfo(settings) {
75
- const model = process.env.HAZE_MODEL ?? settings.model ?? 'x-ai/grok-build-0.1';
76
- const modelSource = process.env.HAZE_MODEL ? 'HAZE_MODEL env' : settings.model ? 'settings' : 'default';
77
- const baseURL = process.env.OPENAI_BASE_URL ?? settings.baseURL ?? 'https://openrouter.ai/api/v1';
78
- const baseURLSource = process.env.OPENAI_BASE_URL ? 'OPENAI_BASE_URL env' : settings.baseURL ? 'settings' : 'default';
79
- const apiKeySource = process.env.OPENAI_API_KEY ? 'OPENAI_API_KEY env' : settings.apiKey ? '~/.haze/settings.json' : 'missing';
80
- const provider = process.env.OPENAI_BASE_URL
81
- ? 'OpenAI-compatible custom endpoint'
82
- : settings.provider === 'openrouter' || settings.baseURL || settings.apiKey
83
- ? 'OpenRouter'
84
- : 'OpenRouter (not logged in)';
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;
85
120
  return [
86
121
  'Provider configuration',
87
122
  `- Provider: ${provider}`,
88
123
  `- Model: ${model} (${modelSource})`,
89
124
  `- Base URL: ${baseURL} (${baseURLSource})`,
90
- `- API key: ${apiKeySource === 'missing' ? 'not configured; run /login or set OPENAI_API_KEY' : `configured via ${apiKeySource}`}`,
125
+ `- API key: ${apiKeySource === 'missing' ? 'not configured; local providers may not need one' : `configured via ${apiKeySource}`}`,
126
+ `- Configured providers: ${configuredProviders(settings).length}`,
91
127
  ].join('\n');
92
128
  }
93
- function ChatScreen({ debug = false, version }) {
129
+ function ChatScreen({ debug = false, version, continueSession = false, noSession = false }) {
94
130
  const { exit } = useApp();
95
131
  const { stdout } = useStdout();
96
- const height = stdout.rows ?? process.stdout.rows ?? 24;
132
+ const width = stdout.columns ?? process.stdout.columns ?? 80;
97
133
  const [messages, setMessages] = useState([
98
134
  { role: 'system', text: 'Welcome to Haze. Use /help for commands.' }
99
135
  ]);
@@ -101,14 +137,22 @@ function ChatScreen({ debug = false, version }) {
101
137
  const conversationRef = useRef([]);
102
138
  const lastAssistantTextRef = useRef('');
103
139
  const abortControllerRef = useRef(null);
140
+ const sessionRef = useRef(undefined);
141
+ const followUpQueueRef = useRef([]);
104
142
  const [inputHistory, setInputHistory] = useState([]);
105
143
  const [debugLogs, setDebugLogs] = useState([]);
106
144
  const [contextFiles, setContextFiles] = useState([]);
107
145
  const [mode, setMode] = useState('chat');
108
146
  const [busy, setBusy] = useState(false);
109
147
  const [busyLabel, setBusyLabel] = useState('Haze is thinking');
148
+ const [activeGoalStatus, setActiveGoalStatus] = useState();
149
+ const [sessionLabel, setSessionLabel] = useState();
150
+ const [queuedFollowUps, setQueuedFollowUps] = useState([]);
110
151
  const [skills, setSkills] = useState([]);
111
152
  const [branchName, setBranchName] = useState();
153
+ const [modelProviderFilter, setModelProviderFilter] = useState();
154
+ const [selectedProviderName, setSelectedProviderName] = useState();
155
+ const [providerDraft, setProviderDraft] = useState({});
112
156
  useEffect(() => {
113
157
  Promise.all([readSettings(), currentBranchName()]).then(([next, branch]) => {
114
158
  setSettings(next);
@@ -122,6 +166,10 @@ function ChatScreen({ debug = false, version }) {
122
166
  setMessages(m => [...m, { role: 'system', text: startupProviderInfo({}) }]);
123
167
  });
124
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
+ });
125
173
  readInputHistory().then(setInputHistory).catch(() => undefined);
126
174
  readContextFiles().then(setContextFiles).catch(() => undefined);
127
175
  refreshSkills().catch(() => undefined);
@@ -142,56 +190,329 @@ function ChatScreen({ debug = false, version }) {
142
190
  function skillInvocation(value) {
143
191
  if (!value.startsWith('/'))
144
192
  return undefined;
145
- const name = value.slice(1).trim();
146
- if (!name || name.includes(' '))
193
+ const [name, ...args] = value.slice(1).trim().split(/\s+/).filter(Boolean);
194
+ if (!name)
147
195
  return undefined;
148
- return skills.find(skill => skill.name === name);
196
+ const skill = skills.find(candidate => candidate.name === name);
197
+ return skill ? { skill, args: args.join(' ') } : undefined;
149
198
  }
150
199
  function debugLog(line) {
151
200
  if (!debug)
152
201
  return;
153
202
  setDebugLogs(current => [...current.slice(-7), line]);
154
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
+ }
155
233
  function clearConversation() {
156
234
  conversationRef.current = [];
157
235
  lastAssistantTextRef.current = '';
158
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);
240
+ }
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.' }]);
260
+ return;
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)]);
159
267
  }
160
268
  function cancelThinking() {
161
269
  if (!busy)
162
270
  return;
163
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
+ }
164
277
  setBusy(false);
165
278
  }
166
- async function submit(value) {
167
- if (busy)
279
+ function queueFollowUp(value) {
280
+ const trimmed = value.trim();
281
+ if (!trimmed)
168
282
  return;
169
- if (mode === 'apiKey') {
170
- const next = await updateSettings({ provider: 'openrouter', apiKey: value, baseURL: 'https://openrouter.ai/api/v1' });
171
- setSettings(next);
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') {
172
289
  setMode('chat');
173
- setMessages(m => [...m, { role: 'system', text: `OpenRouter login saved to ~/.haze/settings.json. Security theatre completed.\n\n${startupProviderInfo(next)}` }]);
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.' }]);
174
323
  return;
175
324
  }
176
- if (mode === 'model') {
177
- const next = await updateSettings({ model: value });
178
- 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.` }]);
328
+ setMode('chat');
329
+ return;
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}.` }]);
179
339
  setMode('chat');
180
- setMessages(m => [...m, { role: 'system', text: `Model set to ${value}.\n\n${startupProviderInfo(next)}` }]);
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);
181
482
  return;
182
483
  }
183
484
  const invokedSkill = skillInvocation(value);
184
485
  if (invokedSkill) {
185
- await doAgentTurn(`The user explicitly invoked the "${invokedSkill.name}" skill. Call skill_${invokedSkill.name.replace(/[^a-zA-Z0-9_]/g, '_')} and follow its returned instructions.`, value);
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);
186
488
  return;
187
489
  }
188
- const isSkillCreate = /^\/skills? create(?:\s|$)/.test(value);
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
+ }
189
500
  const ctx = {
190
501
  settings,
191
502
  contextFiles,
192
503
  setMode,
504
+ setModelProviderFilter,
193
505
  addSystemMessage: text => setMessages(m => [...m, { role: 'system', text }]),
194
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,
195
516
  runAgentTurn: (prompt, displayValue) => doAgentTurn(prompt, displayValue),
196
517
  refreshContextFiles: async () => { const files = await readContextFiles().catch(() => contextFiles); setContextFiles(files); return files; },
197
518
  updateSettings: async (patch) => {
@@ -209,12 +530,22 @@ function ChatScreen({ debug = false, version }) {
209
530
  result = await handleSlashCommand(value, ctx);
210
531
  }
211
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
+ }
212
538
  const text = error instanceof Error ? error.message : String(error);
213
539
  setMessages(m => [...m, { role: 'system', text: `Skill creation failed: ${text}` }]);
214
540
  return;
215
541
  }
216
542
  finally {
217
543
  if (isSkillCreate) {
544
+ if (skillCreateGoal?.status === 'active') {
545
+ skillCreateGoal.phase = 'done';
546
+ skillCreateGoal.status = 'complete';
547
+ setActiveGoalStatus(undefined);
548
+ }
218
549
  setBusy(false);
219
550
  setBusyLabel('Haze is thinking');
220
551
  }
@@ -222,7 +553,7 @@ function ChatScreen({ debug = false, version }) {
222
553
  if (result === 'exit')
223
554
  return exit();
224
555
  if (result === 'handled') {
225
- if (value === '/skill create' || value.startsWith('/skill create ') || value === '/skills create' || value.startsWith('/skills create ') || value.startsWith('/skill remove ') || value.startsWith('/skills remove ')) {
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 ')) {
226
557
  await refreshSkills().catch(() => undefined);
227
558
  }
228
559
  return;
@@ -231,63 +562,122 @@ function ChatScreen({ debug = false, version }) {
231
562
  }
232
563
  async function doAgentTurn(value, displayValue) {
233
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) {
234
575
  await runAgentTurn(value, displayValue, contextFiles, {
235
- 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
+ },
236
582
  updateMessage: (id, update) => setMessages(m => m.map(msg => msg.id === id ? { ...msg, ...update } : msg)),
237
- 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
+ },
238
589
  setBusy,
239
590
  debugLog,
240
591
  getConversation: () => conversationRef.current,
241
592
  getLastAssistantText: () => lastAssistantTextRef.current,
242
593
  setLastAssistantText: text => { lastAssistantTextRef.current = text; },
243
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
+ },
244
602
  });
245
603
  }
246
604
  const visible = messages.filter(message => !message.hidden);
247
- const placeholder = mode === 'apiKey' ? 'OpenRouter API key' : mode === 'model' ? 'x-ai/grok-build-0.1' : busy ? 'Thinking, allegedly' : 'Ask Haze to help build your app';
248
- const activeModelName = process.env.HAZE_MODEL ?? settings.model ?? 'x-ai/grok-build-0.1';
249
- const hasLogin = Boolean(process.env.OPENAI_API_KEY ?? settings.apiKey);
250
- const hasChosenModel = Boolean(process.env.HAZE_MODEL ?? settings.model);
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);
251
628
  const headerSubtitle = hasLogin && hasChosenModel
252
629
  ? [
253
630
  'A minimal LLM harness for growing your own workflows while you work.',
254
631
  '',
255
632
  'Start with simple chat, then teach Haze your habits with skills:',
256
- '/skill create review my branch against main — tiny spell, useful goblin.',
633
+ '/create-skill review my branch against main — tiny spell, useful goblin.',
257
634
  '',
258
635
  'The most adaptive workflow is the one you shape as you go.',
259
636
  '',
260
637
  'Guardrails are light: Haze lets the LLM work from the terminal almost like you,',
261
638
  'while trying to stay scoped to this project.',
262
639
  ].join('\n')
263
- : 'First things first: run /login to add your API key, then /model x-ai/grok-build-0.1 to choose a model.';
640
+ : 'First things first: run /provider to choose or add a provider, then select a model.';
264
641
  const workspaceLabel = `${process.cwd()}${branchName ? ` (${branchName})` : ''}`;
265
642
  const toolsUsed = toolCallCount(messages);
266
643
  const estimatedTokens = estimateConversationTokens(messages);
267
- 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'}`;
268
- const slashSuggestions = mode === 'chat' ? [
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' ? [
269
650
  { value: '/help', description: 'Show commands', kind: 'command' },
270
- { value: '/login', description: 'Save an OpenRouter API key', kind: 'command' },
651
+ { value: '/provider', description: 'Choose a provider', kind: 'command' },
271
652
  { value: '/model', description: 'Choose a model', kind: 'command' },
272
653
  { value: '/settings', description: 'Show provider, model, API key, and context status', kind: 'command' },
273
- { value: '/skill create ', description: 'Create a Markdown skill', kind: 'command' },
274
- { value: '/skill list', description: 'List installed skills', kind: 'command' },
275
- { value: '/skill info ', description: 'Show details for a skill', kind: 'command' },
276
- { value: '/skill validate ', description: 'Validate a skill', kind: 'command' },
277
- { value: '/skill remove ', description: 'Remove a skill with --yes', 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' },
278
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' },
279
664
  { value: '/clear', description: 'Clear conversation history', kind: 'command' },
280
665
  { value: '/exit', description: 'Exit Haze', kind: 'command' },
281
666
  { value: '/quit', description: 'Exit Haze', kind: 'command' },
282
667
  ...skills.map(skill => ({ value: `/${skill.name}`, description: skill.description, kind: 'skill' })),
283
668
  ] : [];
284
- return _jsxs(Box, { flexDirection: "column", minHeight: height, children: [_jsx(Box, { flexShrink: 0, children: _jsx(Header, { subtitle: headerSubtitle, version: version }) }), _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.blue : theme.muted, bold: true, children: message.role === 'user' ? 'You' : message.role === 'assistant' ? 'Haze' : message.role === 'tool' ? 'Tool' : 'Info' }), message.role === 'tool'
285
- ? _jsx(ToolMessageText, { text: message.text, streaming: message.streaming })
286
- : message.role === 'assistant' && !message.streaming
287
- ? _jsx(MarkdownText, { content: message.text })
288
- : _jsx(Text, { children: 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" }), " ", busyLabel, _jsx(Text, { dimColor: true, children: " \u00B7 esc to interrupt" })] }) }), _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, mask: mode === 'apiKey', historyItems: inputHistory, recordHistory: mode === 'chat', suggestions: slashSuggestions, onHistoryAdd: persistInputHistory, onCancel: cancelThinking, 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 }) })] })] });
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 }) })] })] });
289
676
  }
290
677
  export async function chatCommand(options = {}) {
291
- 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 }));
292
682
  await app.waitUntilExit();
293
683
  }