@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
@@ -27,42 +27,67 @@ description: Use when the user asks Haze to create a new skill from a natural-la
27
27
  You create predictable, high-quality Haze skills.
28
28
 
29
29
  A Haze skill is a directory in ~/.haze/skills containing SKILL.md and optional referenced files.
30
- SKILL.md must be Markdown with YAML frontmatter:
30
+ SKILL.md must be Markdown with YAML frontmatter, followed by a role and focused prompt:
31
31
 
32
32
  ---
33
33
  name: kebab-case-name
34
34
  description: Use when the user asks ...
35
35
  ---
36
36
 
37
+ # Role
38
+ You are a focused, practical assistant for this workflow.
39
+
40
+ # Focused prompt
41
+ Complete the user's goal with the smallest reliable workflow.
42
+
37
43
  The description must tell the model exactly when to use the skill.
38
44
  The body must be a deterministic operating procedure, not generic advice.
45
+ Keep skills simple, short, and practical: prefer the fewest commands and sections that reliably complete the workflow.
46
+ Avoid exhaustive checklists, rigid citation requirements, or heavyweight output formats unless the user's request truly requires them.
39
47
  Additional files are allowed only when SKILL.md explicitly references them with relative paths.
40
48
  Skills do not execute code. They teach Haze how to behave for a workflow.
41
49
 
42
- Every skill you create must include:
43
- - Goal: the user's underlying intent, outcome, and definition of success inferred from their description.
44
- - Trigger: when to use the skill.
45
- - Inputs to inspect: exact commands/files/state to read, with incremental inspection for large outputs.
46
- - Procedure: ordered steps with fallback paths for empty, missing, or truncated primary inputs.
50
+ Every skill you create must include, in this order:
51
+ - YAML frontmatter.
52
+ - Role: the specific assistant role to adopt for this workflow.
53
+ - Focused prompt: a concise directive that explains the goal and keeps the workflow scoped.
54
+ - Inputs to inspect: only the essential commands/files/state needed for the workflow, with incremental inspection for large outputs.
55
+ - Procedure: a short ordered list with fallback paths for empty, missing, or truncated primary inputs.
47
56
  - Stop conditions: when it is valid to say there is nothing to do.
48
57
  - Blocker policy: concrete conditions that justify stopping, excluding truncation when narrower inspection is possible.
49
- - Output format: predictable sections for the final answer.
50
- - Evidence rule: require final answers to be grounded in actual inspected content.
58
+ - Output template: a compact, reusable final-answer template with predictable headings/placeholders.
59
+ - Evidence rule: require final answers to be grounded in actual inspected content, but do not require exhaustive citations.
51
60
 
52
- Infer the user's intent from their wording and make that intent the explicit goal of the skill. A good goal states what the skill should accomplish, not just what commands it should run.
53
- Avoid fragile skills that stop after one empty command or one truncated command. If a workflow has common edge cases, encode them explicitly.
54
- For diff/review skills, require this pattern: get branch/status/stat/name-only first; if a full diff is empty, inspect staged and unstaged diffs; if a full diff is truncated, inspect targeted per-file diffs or read changed files before reviewing.
61
+ Infer the user's intent from their wording and make that intent explicit in the Focused prompt. A good focused prompt states what the skill should accomplish, not just what commands it should run.
62
+ Favor skills that a small or slower model can follow in one pass. The model should be able to finish with a concise response after a small number of tool calls.
63
+ Avoid fragile skills that stop after one empty command or one truncated command, but do not over-correct by requiring exhaustive inspection. Encode only common, necessary fallbacks.
64
+ For diff/review skills, keep the default path simple: get status/stat/name-only, inspect unstaged and staged diffs if present, and if no changes or target exist, return a short no-changes response. Use targeted per-file diffs only when the full diff is too large or truncated.
55
65
  Do not require exact line citations for every finding in generated skills; require concrete file/function/code-area evidence instead, with exact lines only when available.
56
66
  `;
57
67
  const generatedSkillSchema = z.object({
58
- name: z.string().min(1).describe('Kebab-case skill name'),
68
+ name: z.string().min(1).describe('Meaningful kebab-case skill name with 2-4 words'),
59
69
  files: z.array(z.object({
60
70
  path: z.string().min(1).describe('Relative file path inside the skill directory'),
61
71
  content: z.string().describe('Complete file content'),
62
72
  })).min(1).describe('Generated skill files, including SKILL.md'),
63
73
  });
74
+ const SKILL_NAME_STOP_WORDS = new Set([
75
+ 'a', 'an', 'the', 'to', 'for', 'with', 'and', 'or', 'of', 'in', 'on', 'my', 'our', 'me', 'i', 'from', 'against', 'as', 'by', 'into', 'using', 'use', 'when', 'asks', 'ask',
76
+ ]);
77
+ const SKILL_NAME_TRAILING_FILLER_WORDS = new Set([
78
+ 'write', 'create', 'make', 'build', 'generate', 'do', 'run', 'handle', 'help', 'using', 'with', 'for', 'to',
79
+ ]);
64
80
  export function slug(s) {
65
- return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'custom-skill';
81
+ const rawWords = s.toLowerCase().match(/[a-z0-9]+/g) ?? [];
82
+ const meaningfulWords = rawWords.filter(word => !SKILL_NAME_STOP_WORDS.has(word));
83
+ const words = (meaningfulWords.length >= 2 ? meaningfulWords : rawWords).slice(0, 4);
84
+ while (words.length > 2 && SKILL_NAME_TRAILING_FILLER_WORDS.has(words.at(-1) ?? ''))
85
+ words.pop();
86
+ if (words.length === 0)
87
+ return 'custom-skill';
88
+ if (words.length === 1)
89
+ words.push(words[0] === 'custom' ? 'skill' : 'workflow');
90
+ return words.join('-');
66
91
  }
67
92
  function yamlString(value) {
68
93
  return JSON.stringify(value);
@@ -73,13 +98,30 @@ function fallbackSkill(description) {
73
98
  name,
74
99
  files: [{
75
100
  path: 'SKILL.md',
76
- content: `---\nname: ${name}\ndescription: ${yamlString(`Use when the user asks: ${description}`)}\n---\n\n# Goal\n\nAccomplish the user's intended outcome: ${description}\n\n# Trigger\n\nUse this skill when the user asks: ${description}\n\n# Inputs to inspect\n\nIdentify the concrete commands, files, diffs, logs, or project state needed for this workflow. Inspect actual content, not only summaries.\n\n# Procedure\n\n1. Confirm the relevant project state.\n2. Inspect the primary input for the workflow.\n3. If the primary input is empty, unavailable, or truncated, inspect natural fallback inputs or narrower targeted inputs before stopping.\n4. For large inputs, inspect summaries first, then targeted files, sections, or commands most relevant to the goal.\n5. Perform the requested analysis or implementation using the inspected evidence.\n6. Produce the final answer in the output format below.\n\n# Stop conditions\n\nOnly say there is nothing to do after every relevant input source has been checked and is empty.\n\n# Blocker policy\n\nOnly stop as blocked for a concrete tool failure, missing permission, unavailable dependency, or ambiguous requirement that prevents progress. Truncated output is not a blocker when narrower follow-up inspection is possible.\n\n# Output format\n\n- Summary\n- Findings or actions\n- Evidence inspected\n- Recommendation or next step\n${STANDARD_SKILL_REQUIREMENTS}\n# References\n\nAdd relative file references here if this skill needs examples, templates, or supporting docs.\n`,
101
+ content: `---\nname: ${name}\ndescription: ${yamlString(`Use when the user asks: ${description}`)}\n---\n\n# Role\n\nYou are a focused, practical assistant for this workflow.\n\n# Focused prompt\n\nAccomplish the user's intended outcome with the smallest reliable workflow: ${description}\n\n# Inputs to inspect\n\nIdentify the concrete commands, files, diffs, logs, or project state needed for this workflow. Inspect actual content, not only summaries.\n\n# Procedure\n\n1. Confirm the relevant project state.\n2. Inspect the primary input for the workflow.\n3. If the primary input is empty, unavailable, or truncated, inspect natural fallback inputs or narrower targeted inputs before stopping.\n4. For large inputs, inspect summaries first, then targeted files, sections, or commands most relevant to the goal.\n5. Perform the requested analysis or implementation using the inspected evidence.\n6. Produce the final answer using the output template below.\n\n# Stop conditions\n\nOnly say there is nothing to do after every relevant input source has been checked and is empty.\n\n# Blocker policy\n\nOnly stop as blocked for a concrete tool failure, missing permission, unavailable dependency, or ambiguous requirement that prevents progress. Truncated output is not a blocker when narrower follow-up inspection is possible.\n\n# Output template\n\n## Summary\n- <one-to-three bullets with the result>\n\n## Actions or findings\n- <concrete actions taken or findings discovered>\n\n## Evidence inspected\n- <commands, files, diffs, or outputs used>\n\n## Next step\n- <recommended next action, or "None" if complete>\n${STANDARD_SKILL_REQUIREMENTS}\n# References\n\nAdd relative file references here if this skill needs examples, templates, or supporting docs.\n`,
77
102
  }],
78
103
  };
79
104
  }
80
105
  function withStandardRequirements(content) {
81
106
  return content.includes('# Operational guardrails') ? content : `${content.trim()}${STANDARD_SKILL_REQUIREMENTS}\n`;
82
107
  }
108
+ function withSkillName(content, name) {
109
+ if (/^---\n[\s\S]*?^name:\s*.*$/m.test(content))
110
+ return content.replace(/^(---\n[\s\S]*?^name:\s*).*$/m, `$1${name}`);
111
+ return content;
112
+ }
113
+ function normalizeSkillDescription(description) {
114
+ const trimmed = description.replace(/\s+/g, ' ').trim();
115
+ if (!trimmed)
116
+ return 'Use when the user asks for this workflow.';
117
+ return /^use when\b/i.test(trimmed) ? trimmed : `Use when ${trimmed.charAt(0).toLowerCase()}${trimmed.slice(1)}`;
118
+ }
119
+ function withSkillDescription(content, description) {
120
+ const normalized = normalizeSkillDescription(description);
121
+ if (/^---\n[\s\S]*?^description:\s*.*$/m.test(content))
122
+ return content.replace(/^(---\n[\s\S]*?^description:\s*).*$/m, `$1${yamlString(normalized)}`);
123
+ return content;
124
+ }
83
125
  function extractJson(text) {
84
126
  const fenced = /```(?:json)?\s*([\s\S]*?)\s*```/.exec(text);
85
127
  return fenced?.[1] ?? text;
@@ -94,6 +136,71 @@ function parseGeneratedSkill(text, description) {
94
136
  throw new Error('Generated skill did not include SKILL.md');
95
137
  return { name, files };
96
138
  }
139
+ async function descriptionFromSkillSummary(description, finalName, files) {
140
+ const skillMd = files.find(file => file.path === 'SKILL.md')?.content;
141
+ if (!skillMd)
142
+ return normalizeSkillDescription(`the user asks: ${description}`);
143
+ const activeModel = await model();
144
+ if (!activeModel)
145
+ return normalizeSkillDescription(`the user asks: ${description}`);
146
+ const result = await generateObject({
147
+ model: activeModel,
148
+ temperature: 0,
149
+ schema: z.object({ description: z.string().min(1).describe('Final Use when description that tells an LLM when to invoke this skill') }),
150
+ schemaName: 'GeneratedHazeSkillDescription',
151
+ schemaDescription: 'A final skill description chosen from the complete generated SKILL.md.',
152
+ prompt: [
153
+ 'Write the final Haze skill frontmatter description after reading the entire generated SKILL.md.',
154
+ '',
155
+ `Original user request: ${description}`,
156
+ `Final skill name: ${finalName}`,
157
+ '',
158
+ 'Description rules:',
159
+ '- Start with "Use when".',
160
+ '- Optimize for LLM understandability: make it obvious when this skill should be invoked.',
161
+ '- Summarize the actual workflow in the SKILL.md, not only the user wording.',
162
+ '- Be specific about the trigger and desired outcome.',
163
+ '- Keep it one sentence, concise but complete.',
164
+ '- Avoid vague descriptions like "Use when the user asks for this workflow" unless no better signal exists.',
165
+ '',
166
+ 'Generated SKILL.md:',
167
+ skillMd,
168
+ ].join('\n'),
169
+ });
170
+ return normalizeSkillDescription(result.object.description || `the user asks: ${description}`);
171
+ }
172
+ async function nameFromSkillSummary(description, generatedName, files) {
173
+ const skillMd = files.find(file => file.path === 'SKILL.md')?.content;
174
+ if (!skillMd)
175
+ return slug(generatedName || description);
176
+ const activeModel = await model();
177
+ if (!activeModel)
178
+ return slug(generatedName || description);
179
+ const result = await generateObject({
180
+ model: activeModel,
181
+ temperature: 0,
182
+ schema: z.object({ name: z.string().min(1).describe('Final meaningful 2-4 word kebab-case skill name') }),
183
+ schemaName: 'GeneratedHazeSkillName',
184
+ schemaDescription: 'A final skill name chosen from the complete generated SKILL.md.',
185
+ prompt: [
186
+ 'Choose the final Haze skill directory name after reading the entire generated SKILL.md.',
187
+ '',
188
+ `Original user request: ${description}`,
189
+ `Draft/generated name: ${generatedName}`,
190
+ '',
191
+ 'Naming rules:',
192
+ '- Return 2-4 meaningful words in kebab-case.',
193
+ '- The name must summarize the whole skill workflow, not just copy the first words of the request.',
194
+ '- Do not include a loose trailing word from a cut-off sentence. Bad: commit-current-changes-write. Good: commit-current-changes.',
195
+ '- Prefer nouns that convey the outcome, such as review, commit, release-notes, migration, validation, or triage.',
196
+ '- Avoid vague words like helper, workflow, custom-skill, write, create, make, or do unless they are essential to the meaning.',
197
+ '',
198
+ 'Generated SKILL.md:',
199
+ skillMd,
200
+ ].join('\n'),
201
+ });
202
+ return slug(result.object.name || generatedName || description);
203
+ }
97
204
  function assertSafeGeneratedFile(filePath) {
98
205
  if (path.isAbsolute(filePath))
99
206
  throw new Error(`Generated skill file must be relative: ${filePath}`);
@@ -107,7 +214,7 @@ function assertSafeGeneratedFile(filePath) {
107
214
  async function generateSkill(description) {
108
215
  const activeModel = await model();
109
216
  if (!activeModel)
110
- throw new Error('No API key configured. Run /login, then /model x-ai/grok-build-0.1, before using /skill create.');
217
+ throw new Error('No model provider configured. Run /provider to choose or add a provider before using /create-skill.');
111
218
  const result = await generateObject({
112
219
  model: activeModel,
113
220
  temperature: 0,
@@ -121,12 +228,20 @@ async function generateSkill(description) {
121
228
  '',
122
229
  'Rules:',
123
230
  '- SKILL.md must include frontmatter with name and description.',
231
+ '- The skill name must be 2-4 meaningful words in kebab-case and convey the workflow intent, for example "branch-diff-review" or "release-notes-draft".',
232
+ '- Pick the name as if it were written after summarizing the complete SKILL.md, not by truncating the first words of the request.',
233
+ '- Do not include loose trailing words from cut-off sentences. Bad: "commit-current-changes-write". Good: "commit-current-changes".',
234
+ '- Avoid vague names like "helper", "workflow", or "custom-skill" unless paired with a specific domain word.',
124
235
  '- The frontmatter description must start with "Use when".',
125
- '- Infer the user intent from the description and make it the explicit Goal of the skill.',
126
- '- The body must use these headings: Goal, Trigger, Inputs to inspect, Procedure, Fallbacks, Stop conditions, Blocker policy, Output format.',
127
- '- The Goal must describe the desired outcome and definition of success, not just restate the trigger.',
128
- '- Procedure steps must name exact commands/files/state to inspect whenever the workflow implies them.',
129
- '- Include fallback behavior for empty or truncated primary inputs. Example: if branch diff is empty, inspect staged and unstaged changes before stopping; if full diff is truncated, inspect per-file diffs or read changed files.',
236
+ '- Infer the user intent from the description and make it explicit in the Focused prompt.',
237
+ '- The body must start with these headings immediately after frontmatter: Role, Focused prompt.',
238
+ '- The body must then include these headings: Inputs to inspect, Procedure, Fallbacks, Stop conditions, Blocker policy, Output template.',
239
+ '- The Focused prompt must describe the desired outcome and definition of success, not just restate the trigger.',
240
+ '- Keep the skill simple enough for a small or slower model to complete in one pass.',
241
+ '- Prefer short procedures, compact final output templates, and the minimum necessary tool calls.',
242
+ '- Avoid exhaustive checklists, mandatory line citations for every claim, and large rigid report templates unless the user explicitly asks for them.',
243
+ '- Procedure steps must name exact commands/files/state to inspect whenever the workflow implies them, but only include essential inspections.',
244
+ '- Include fallback behavior for empty or truncated primary inputs. Example: if branch diff is empty, inspect staged and unstaged changes before stopping; if no changes or target exist, return a concise no-changes response; if full diff is truncated, inspect per-file diffs or read changed files.',
130
245
  '- For workflows with potentially large outputs, require incremental inspection: stat/name-only/summary first, then targeted content reads.',
131
246
  '- Define when it is valid to say "nothing to do".',
132
247
  '- Define blockers narrowly: concrete tool failure, missing permission/dependency, or ambiguous requirement. Truncation alone is not a blocker if targeted follow-up inspection is possible.',
@@ -136,11 +251,18 @@ async function generateSkill(description) {
136
251
  ].join('\n'),
137
252
  });
138
253
  const generated = result.object;
139
- return { name: slug(generated.name || description), files: generated.files };
254
+ const draftName = slug(generated.name || description);
255
+ const finalName = await nameFromSkillSummary(description, draftName, generated.files).catch(() => draftName);
256
+ const finalDescription = await descriptionFromSkillSummary(description, finalName, generated.files).catch(() => normalizeSkillDescription(`the user asks: ${description}`));
257
+ const files = generated.files.map(file => file.path === 'SKILL.md' ? { ...file, content: withSkillDescription(file.content, finalDescription) } : file);
258
+ return { name: finalName, files };
140
259
  }
141
260
  export async function createSkill(description) {
142
261
  const generated = await generateSkill(description).catch(error => {
143
- throw error instanceof Error ? error : new Error(String(error));
262
+ const message = error instanceof Error ? error.message : String(error);
263
+ if (message.startsWith('No model provider configured.'))
264
+ throw error instanceof Error ? error : new Error(message);
265
+ return fallbackSkill(description);
144
266
  });
145
267
  const name = generated.name || fallbackSkill(description).name;
146
268
  const dir = path.join(GLOBAL_SKILLS_DIR, name);
@@ -152,7 +274,7 @@ export async function createSkill(description) {
152
274
  const safePath = assertSafeGeneratedFile(generatedFile.path);
153
275
  const absolutePath = path.join(dir, safePath);
154
276
  await fs.ensureDir(path.dirname(absolutePath));
155
- const content = safePath === 'SKILL.md' ? withStandardRequirements(generatedFile.content) : generatedFile.content;
277
+ const content = safePath === 'SKILL.md' ? withSkillName(withStandardRequirements(generatedFile.content), name) : generatedFile.content;
156
278
  await fs.writeFile(absolutePath, content, 'utf8');
157
279
  }
158
280
  if (!(await fs.pathExists(skillFile))) {
@@ -171,4 +293,4 @@ export async function createSkill(description) {
171
293
  }
172
294
  return { name, dir, file: skillFile };
173
295
  }
174
- export const internals = { SKILL_CREATOR_SKILL, STANDARD_SKILL_REQUIREMENTS, parseGeneratedSkill, fallbackSkill, withStandardRequirements };
296
+ export const internals = { SKILL_CREATOR_SKILL, STANDARD_SKILL_REQUIREMENTS, parseGeneratedSkill, fallbackSkill, withStandardRequirements, withSkillName, withSkillDescription, normalizeSkillDescription };
@@ -1,3 +1,4 @@
1
+ import React from 'react';
1
2
  export declare function ErrorView({ error }: {
2
3
  error: unknown;
3
- }): import("react/jsx-runtime").JSX.Element;
4
+ }): React.JSX.Element;
@@ -1,4 +1,5 @@
1
+ import React from 'react';
1
2
  export declare function Header({ subtitle, version }: {
2
3
  subtitle?: string;
3
4
  version?: string;
4
- }): import("react/jsx-runtime").JSX.Element;
5
+ }): React.JSX.Element;
@@ -1,16 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { theme } from '../theme.js';
4
- const logo = [
5
- ' _ ',
6
- ' | | ',
7
- ' | |__ __ _ _______ ',
8
- " | '_ \\ / _` |_ / _ \\",
9
- ' | | | | (_| |/ / __/',
10
- ' |_| |_|\\__,_/___\\___|',
11
- ];
12
4
  export function Header({ subtitle, version }) {
13
- return _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [logo.map((line, index) => index === logo.length - 1
14
- ? _jsxs(Box, { children: [_jsx(Text, { color: index % 2 === 0 ? theme.purple : theme.violet, bold: true, children: line }), version ? _jsxs(Text, { color: theme.muted, children: [" v", version] }) : null] }, line)
15
- : _jsx(Text, { color: index % 2 === 0 ? theme.purple : theme.violet, bold: true, children: line }, line)), _jsx(Text, { children: " " }), _jsx(Text, { color: theme.muted, children: subtitle ?? 'A tiny terminal fog machine for building software.' })] });
5
+ return _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.purple, bold: true, children: "haze" }), version ? _jsxs(Text, { color: theme.muted, children: [" v", version] }) : null] }), _jsx(Text, { children: " " }), _jsx(Text, { color: theme.muted, children: subtitle ?? 'A tiny terminal fog machine for building software.' })] });
16
6
  }
@@ -1,3 +1,4 @@
1
+ import React from 'react';
1
2
  export declare function MarkdownText({ content }: {
2
3
  content: string;
3
- }): import("react/jsx-runtime").JSX.Element;
4
+ }): React.JSX.Element;
@@ -1,16 +1,20 @@
1
+ import React from 'react';
1
2
  export type TextInputSuggestion = {
2
3
  value: string;
3
4
  description?: string;
4
- kind?: 'command' | 'skill';
5
+ kind?: 'command' | 'skill' | 'provider' | 'model';
5
6
  };
6
- export declare function TextInput({ placeholder, disabled, mask, historyItems, recordHistory, suggestions, onHistoryAdd, onCancel, onSubmit }: {
7
+ export declare function TextInput({ placeholder, disabled, mask, historyItems, recordHistory, suggestions, suggestionMode, submitOnEmpty, onHistoryAdd, onCancel, onEscape, onSubmit }: {
7
8
  placeholder?: string;
8
9
  disabled?: boolean;
9
10
  mask?: boolean;
10
11
  historyItems?: string[];
11
12
  recordHistory?: boolean;
12
13
  suggestions?: TextInputSuggestion[];
14
+ suggestionMode?: 'slash' | 'always';
15
+ submitOnEmpty?: boolean;
13
16
  onHistoryAdd?: (value: string) => void;
14
17
  onCancel?: () => void;
18
+ onEscape?: () => void;
15
19
  onSubmit: (value: string) => void;
16
- }): import("react/jsx-runtime").JSX.Element;
20
+ }): React.JSX.Element;
@@ -2,12 +2,63 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useEffect, useRef, useState } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import { theme } from '../theme.js';
5
- export function TextInput({ placeholder, disabled, mask, historyItems = [], recordHistory = true, suggestions = [], onHistoryAdd, onCancel, onSubmit }) {
5
+ const COMPACT_PASTE_MIN_LINES = 4;
6
+ function normalizeLineEndings(text) {
7
+ return text.replace(/\r\n|\r/g, '\n');
8
+ }
9
+ function lineCount(text) {
10
+ return normalizeLineEndings(text).split('\n').length;
11
+ }
12
+ function pastePlaceholder(block) {
13
+ return `[paste #${block.id} +${block.lineCount} lines]`;
14
+ }
15
+ function updatePasteBlocksForReplacement(blocks, start, end, insertedLength) {
16
+ const delta = insertedLength - (end - start);
17
+ return blocks.flatMap(block => {
18
+ const replacesInsideBlock = start < block.end && end > block.start;
19
+ const insertsInsideBlock = start === end && start > block.start && start < block.end;
20
+ if (replacesInsideBlock || insertsInsideBlock)
21
+ return [];
22
+ if (block.start >= end)
23
+ return [{ ...block, start: block.start + delta, end: block.end + delta }];
24
+ return [block];
25
+ });
26
+ }
27
+ function displayCursorForValueCursor(blocks, valueCursor) {
28
+ let displayCursor = valueCursor;
29
+ for (const block of [...blocks].sort((a, b) => a.start - b.start)) {
30
+ const placeholderLength = pastePlaceholder(block).length;
31
+ const compactedLength = block.end - block.start - placeholderLength;
32
+ if (valueCursor <= block.start)
33
+ break;
34
+ if (valueCursor < block.end)
35
+ return block.start + placeholderLength;
36
+ displayCursor -= compactedLength;
37
+ }
38
+ return displayCursor;
39
+ }
40
+ function compactPasteBlocksForDisplay(value, blocks) {
41
+ if (blocks.length === 0)
42
+ return value;
43
+ let displayValue = '';
44
+ let offset = 0;
45
+ for (const block of [...blocks].sort((a, b) => a.start - b.start)) {
46
+ displayValue += value.slice(offset, block.start);
47
+ displayValue += pastePlaceholder(block);
48
+ offset = block.end;
49
+ }
50
+ displayValue += value.slice(offset);
51
+ return displayValue;
52
+ }
53
+ export function TextInput({ placeholder, disabled, mask, historyItems = [], recordHistory = true, suggestions = [], suggestionMode = 'slash', submitOnEmpty = false, onHistoryAdd, onCancel, onEscape, onSubmit }) {
6
54
  const [value, setValue] = useState('');
7
55
  const [cursor, setCursor] = useState(0);
56
+ const [pasteBlocks, setPasteBlocks] = useState([]);
57
+ const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(0);
8
58
  const history = useRef(historyItems);
9
59
  const historyIndex = useRef(null);
10
60
  const draft = useRef('');
61
+ const nextPasteId = useRef(1);
11
62
  useEffect(() => {
12
63
  history.current = historyItems;
13
64
  }, [historyItems]);
@@ -15,28 +66,50 @@ export function TextInput({ placeholder, disabled, mask, historyItems = [], reco
15
66
  if (!disabled) {
16
67
  setValue('');
17
68
  setCursor(0);
69
+ setPasteBlocks([]);
70
+ setSelectedSuggestionIndex(0);
18
71
  historyIndex.current = null;
19
72
  draft.current = '';
73
+ nextPasteId.current = 1;
20
74
  }
21
75
  }, [disabled]);
22
- function setInput(next, nextCursor = next.length) {
76
+ function setInput(next, nextCursor = next.length, nextPasteBlocks = []) {
23
77
  setValue(next);
24
78
  setCursor(Math.max(0, Math.min(nextCursor, next.length)));
79
+ setPasteBlocks(nextPasteBlocks);
80
+ setSelectedSuggestionIndex(0);
81
+ }
82
+ function replaceInput(start, end, inserted) {
83
+ const normalizedInserted = normalizeLineEndings(inserted);
84
+ const next = value.slice(0, start) + normalizedInserted + value.slice(end);
85
+ const insertedLineCount = lineCount(normalizedInserted);
86
+ const updatedPasteBlocks = updatePasteBlocksForReplacement(pasteBlocks, start, end, normalizedInserted.length);
87
+ const insertedPasteBlock = !mask && insertedLineCount >= COMPACT_PASTE_MIN_LINES
88
+ ? [{ id: nextPasteId.current++, start, end: start + normalizedInserted.length, lineCount: insertedLineCount }]
89
+ : [];
90
+ setInput(next, start + normalizedInserted.length, [...updatedPasteBlocks, ...insertedPasteBlock]);
91
+ historyIndex.current = null;
25
92
  }
26
93
  function showHistory(index) {
27
94
  historyIndex.current = index;
28
95
  setInput(history.current[index] ?? '');
29
96
  }
30
- const slashQuery = !mask && value.startsWith('/') ? value.slice(1).toLowerCase() : undefined;
31
- const filteredSuggestions = slashQuery == null ? [] : suggestions
32
- .filter(suggestion => suggestion.value.slice(1).toLowerCase().includes(slashQuery) || suggestion.description?.toLowerCase().includes(slashQuery))
97
+ const suggestionQuery = !mask && (suggestionMode === 'always' || value.startsWith('/'))
98
+ ? (suggestionMode === 'always' ? value : value.slice(1)).toLowerCase()
99
+ : undefined;
100
+ const filteredSuggestions = suggestionQuery == null ? [] : suggestions
101
+ .filter(suggestion => {
102
+ const suggestionValue = suggestionMode === 'always' ? suggestion.value : suggestion.value.slice(1);
103
+ return suggestionValue.toLowerCase().includes(suggestionQuery) || suggestion.description?.toLowerCase().includes(suggestionQuery);
104
+ })
33
105
  .slice(0, 8);
34
- const topSuggestion = filteredSuggestions[0];
35
- function submitValue(submitted) {
36
- if (recordHistory) {
37
- if (history.current[history.current.length - 1] !== submitted)
38
- history.current = [...history.current, submitted];
39
- onHistoryAdd?.(submitted);
106
+ const activeSuggestionIndex = Math.min(selectedSuggestionIndex, Math.max(0, filteredSuggestions.length - 1));
107
+ const activeSuggestion = filteredSuggestions[activeSuggestionIndex];
108
+ function submitValue(submitted, historyValue = submitted) {
109
+ if (recordHistory && historyValue) {
110
+ if (history.current[history.current.length - 1] !== historyValue)
111
+ history.current = [...history.current, historyValue];
112
+ onHistoryAdd?.(historyValue);
40
113
  }
41
114
  onSubmit(submitted);
42
115
  }
@@ -50,20 +123,26 @@ export function TextInput({ placeholder, disabled, mask, historyItems = [], reco
50
123
  setInput('');
51
124
  historyIndex.current = null;
52
125
  draft.current = '';
126
+ nextPasteId.current = 1;
127
+ onEscape?.();
53
128
  return;
54
129
  }
55
- if (key.tab && topSuggestion) {
56
- setInput(topSuggestion.value);
130
+ if (key.tab && activeSuggestion) {
131
+ setInput(activeSuggestion.value);
57
132
  historyIndex.current = null;
58
133
  return;
59
134
  }
60
135
  if (key.return) {
61
- const submitted = (value.startsWith('/') && topSuggestion && topSuggestion.value !== value.trim()) ? topSuggestion.value : value.trim();
136
+ const shouldUseSuggestion = activeSuggestion && activeSuggestion.value !== value.trim() && (suggestionMode === 'always' || value.startsWith('/'));
137
+ const submitted = shouldUseSuggestion ? activeSuggestion.value : value.trim();
138
+ const submittedSuggestion = activeSuggestion?.value === submitted ? activeSuggestion : undefined;
139
+ const historyValue = submittedSuggestion && submittedSuggestion.kind !== 'command' ? '' : submitted;
62
140
  setInput('');
63
141
  historyIndex.current = null;
64
142
  draft.current = '';
65
- if (submitted)
66
- submitValue(submitted);
143
+ nextPasteId.current = 1;
144
+ if (submitted || submitOnEmpty)
145
+ submitValue(submitted, historyValue);
67
146
  return;
68
147
  }
69
148
  if (key.leftArrow) {
@@ -75,6 +154,10 @@ export function TextInput({ placeholder, disabled, mask, historyItems = [], reco
75
154
  return;
76
155
  }
77
156
  if (key.upArrow) {
157
+ if (filteredSuggestions.length > 0 && activeSuggestionIndex > 0) {
158
+ setSelectedSuggestionIndex(current => Math.max(0, current - 1));
159
+ return;
160
+ }
78
161
  if (history.current.length === 0)
79
162
  return;
80
163
  if (historyIndex.current === null) {
@@ -87,6 +170,10 @@ export function TextInput({ placeholder, disabled, mask, historyItems = [], reco
87
170
  return;
88
171
  }
89
172
  if (key.downArrow) {
173
+ if (filteredSuggestions.length > 0 && activeSuggestionIndex < filteredSuggestions.length - 1) {
174
+ setSelectedSuggestionIndex(current => Math.min(filteredSuggestions.length - 1, current + 1));
175
+ return;
176
+ }
90
177
  if (historyIndex.current === null)
91
178
  return;
92
179
  if (historyIndex.current < history.current.length - 1) {
@@ -101,15 +188,13 @@ export function TextInput({ placeholder, disabled, mask, historyItems = [], reco
101
188
  if (key.backspace) {
102
189
  if (cursor === 0)
103
190
  return;
104
- setInput(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1);
105
- historyIndex.current = null;
191
+ replaceInput(cursor - 1, cursor, '');
106
192
  return;
107
193
  }
108
194
  if (key.delete) {
109
195
  if (cursor >= value.length)
110
196
  return;
111
- setInput(value.slice(0, cursor) + value.slice(cursor + 1), cursor);
112
- historyIndex.current = null;
197
+ replaceInput(cursor, cursor + 1, '');
113
198
  return;
114
199
  }
115
200
  if (key.ctrl && input === 'a') {
@@ -123,13 +208,13 @@ export function TextInput({ placeholder, disabled, mask, historyItems = [], reco
123
208
  if (key.ctrl && input === 'c')
124
209
  return;
125
210
  if (input) {
126
- setInput(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length);
127
- historyIndex.current = null;
211
+ replaceInput(cursor, cursor, input);
128
212
  }
129
213
  });
130
- const displayValue = mask ? '•'.repeat(value.length) : value;
131
- const beforeCursor = displayValue.slice(0, cursor);
132
- const cursorChar = displayValue[cursor] ?? ' ';
133
- const afterCursor = displayValue.slice(cursor + 1);
134
- return _jsxs(Box, { flexDirection: "column", width: "100%", children: [filteredSuggestions.length > 0 && _jsx(Box, { flexDirection: "column", marginBottom: 1, children: filteredSuggestions.map((suggestion, index) => _jsxs(Text, { color: index === 0 ? theme.success : theme.muted, wrap: "truncate-end", children: [index === 0 ? '› ' : ' ', suggestion.value, _jsxs(Text, { color: theme.muted, children: [" ", suggestion.kind === 'skill' ? 'skill' : 'command', suggestion.description ? ` — ${suggestion.description}` : ''] })] }, suggestion.value)) }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: theme.purple, children: "\u203A " }), value.length === 0 ? _jsxs(_Fragment, { children: [_jsx(Text, { inverse: true, children: " " }), _jsxs(Text, { color: theme.muted, dimColor: true, children: [" ", placeholder ?? 'Type a message...'] })] }) : _jsxs(_Fragment, { children: [beforeCursor, _jsx(Text, { inverse: true, children: cursorChar }), afterCursor] })] })] });
214
+ const displayValue = mask ? '•'.repeat(value.length) : compactPasteBlocksForDisplay(value, pasteBlocks);
215
+ const displayCursor = mask ? cursor : displayCursorForValueCursor(pasteBlocks, cursor);
216
+ const beforeCursor = displayValue.slice(0, displayCursor);
217
+ const cursorChar = displayValue[displayCursor] ?? ' ';
218
+ const afterCursor = displayValue.slice(displayCursor + 1);
219
+ return _jsxs(Box, { flexDirection: "column", width: "100%", children: [filteredSuggestions.length > 0 && _jsx(Box, { flexDirection: "column", marginBottom: 1, children: filteredSuggestions.map((suggestion, index) => _jsxs(Text, { color: index === activeSuggestionIndex ? theme.success : theme.muted, wrap: "truncate-end", children: [index === activeSuggestionIndex ? '› ' : ' ', suggestion.value, _jsxs(Text, { color: theme.muted, children: [" ", suggestion.kind ?? 'command', suggestion.description ? ` — ${suggestion.description}` : ''] })] }, suggestion.value)) }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: theme.purple, children: "\u203A " }), value.length === 0 ? _jsxs(_Fragment, { children: [_jsx(Text, { inverse: true, children: " " }), _jsxs(Text, { color: theme.muted, dimColor: true, children: [" ", placeholder ?? 'Type a message...'] })] }) : _jsxs(_Fragment, { children: [beforeCursor, _jsx(Text, { inverse: true, children: cursorChar }), afterCursor] })] })] });
135
220
  }
@@ -7,6 +7,7 @@ export declare const theme: {
7
7
  danger: string;
8
8
  success: string;
9
9
  warning: string;
10
+ orange: string;
10
11
  codeBg: string;
11
12
  quoteBg: string;
12
13
  };
package/dist/ui/theme.js CHANGED
@@ -5,8 +5,9 @@ export const theme = {
5
5
  blue: '#60a5fa',
6
6
  muted: '#9ca3af',
7
7
  danger: '#fb7185',
8
- success: '#34d399',
8
+ success: '#39ff14',
9
9
  warning: '#fbbf24',
10
+ orange: '#f59e0b',
10
11
  codeBg: '#1f1633',
11
12
  quoteBg: '#171127'
12
13
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@denizokcu/haze",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "A pragmatic agentic CLI for building apps from the terminal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -44,15 +44,15 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@ai-sdk/openai": "3.0.67",
47
- "@inquirer/prompts": "8.5.1",
48
- "ai": "6.0.193",
47
+ "@inquirer/prompts": "8.5.2",
48
+ "ai": "6.0.194",
49
49
  "cli-highlight": "2.1.11",
50
50
  "commander": "15.0.0",
51
51
  "fs-extra": "11.3.5",
52
52
  "ink": "7.0.5",
53
53
  "ink-spinner": "5.0.0",
54
54
  "marked": "18.0.4",
55
- "react": "19.2.6",
55
+ "react": "19.2.7",
56
56
  "strip-ansi": "7.2.0",
57
57
  "yaml": "2.9.0",
58
58
  "zod": "4.4.3"
@@ -61,11 +61,11 @@
61
61
  "@eslint/js": "^10.0.1",
62
62
  "@types/fs-extra": "11.0.4",
63
63
  "@types/node": "25.9.1",
64
- "@types/react": "19.2.15",
64
+ "@types/react": "19.2.16",
65
65
  "eslint": "^10.4.1",
66
- "tsx": "4.22.3",
66
+ "tsx": "4.22.4",
67
67
  "typescript": "6.0.3",
68
- "typescript-eslint": "^8.60.0",
69
- "vitest": "^4.1.7"
68
+ "typescript-eslint": "^8.60.1",
69
+ "vitest": "^4.1.8"
70
70
  }
71
71
  }