@denizokcu/haze 0.0.2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +100 -34
- package/dist/cli/commands/chat.d.ts +3 -1
- package/dist/cli/commands/chat.js +500 -56
- package/dist/cli/commands/commands.d.ts +5 -0
- package/dist/cli/commands/commands.js +114 -29
- package/dist/cli/commands/formatters.js +32 -2
- package/dist/cli/commands/streaming.d.ts +6 -1
- package/dist/cli/commands/streaming.js +316 -98
- package/dist/cli/index.js +5 -2
- package/dist/config/inputHistory.js +8 -0
- package/dist/config/providers.d.ts +26 -0
- package/dist/config/providers.js +88 -0
- package/dist/config/settings.d.ts +9 -2
- package/dist/core/agent/compaction.d.ts +13 -0
- package/dist/core/agent/compaction.js +34 -0
- package/dist/core/agent/errors.d.ts +3 -0
- package/dist/core/agent/errors.js +13 -0
- package/dist/core/agent/events.d.ts +58 -0
- package/dist/core/agent/events.js +3 -0
- package/dist/core/goal/completionPolicy.d.ts +28 -0
- package/dist/core/goal/completionPolicy.js +84 -0
- package/dist/core/goal/requestClassifier.d.ts +6 -0
- package/dist/core/goal/requestClassifier.js +31 -0
- package/dist/core/goal/sessionGoal.d.ts +30 -0
- package/dist/core/goal/sessionGoal.js +88 -0
- package/dist/core/session/sessionStore.d.ts +37 -0
- package/dist/core/session/sessionStore.js +59 -0
- package/dist/core/subagent/subagentRunner.d.ts +33 -0
- package/dist/core/subagent/subagentRunner.js +140 -0
- package/dist/llm/client.d.ts +1 -1
- package/dist/llm/client.js +6 -6
- package/dist/llm/hazeTools.d.ts +86 -0
- package/dist/llm/hazeTools.js +313 -93
- package/dist/llm/initPrompt.js +6 -4
- package/dist/llm/systemPrompt.js +11 -7
- package/dist/skills/builder/SkillBuilder.d.ts +6 -0
- package/dist/skills/builder/SkillBuilder.js +146 -24
- package/dist/ui/components/ErrorView.d.ts +2 -1
- package/dist/ui/components/Header.d.ts +2 -1
- package/dist/ui/components/Header.js +1 -11
- package/dist/ui/components/MarkdownText.d.ts +2 -1
- package/dist/ui/components/TextInput.d.ts +7 -3
- package/dist/ui/components/TextInput.js +112 -27
- package/dist/ui/theme.d.ts +3 -0
- package/dist/ui/theme.js +4 -1
- 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
|
-
-
|
|
44
|
-
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
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
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
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('
|
|
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
|
-
|
|
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#
|
|
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
|
|
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
|
|
126
|
-
'- The body must
|
|
127
|
-
'- The
|
|
128
|
-
'-
|
|
129
|
-
'-
|
|
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
|
-
|
|
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
|
-
|
|
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,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: [
|
|
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,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
|
-
}):
|
|
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
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 &&
|
|
56
|
-
setInput(
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
historyIndex.current = null;
|
|
211
|
+
replaceInput(cursor, cursor, input);
|
|
128
212
|
}
|
|
129
213
|
});
|
|
130
|
-
const displayValue = mask ? '•'.repeat(value.length) : value;
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
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
|
}
|
package/dist/ui/theme.d.ts
CHANGED
package/dist/ui/theme.js
CHANGED
|
@@ -5,8 +5,11 @@ export const theme = {
|
|
|
5
5
|
blue: '#60a5fa',
|
|
6
6
|
muted: '#9ca3af',
|
|
7
7
|
danger: '#fb7185',
|
|
8
|
-
|
|
8
|
+
dangerBg: '#3a1720',
|
|
9
|
+
success: '#39ff14',
|
|
10
|
+
successBg: '#14331f',
|
|
9
11
|
warning: '#fbbf24',
|
|
12
|
+
orange: '#f59e0b',
|
|
10
13
|
codeBg: '#1f1633',
|
|
11
14
|
quoteBg: '#171127'
|
|
12
15
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@denizokcu/haze",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
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.
|
|
48
|
-
"ai": "6.0.
|
|
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.
|
|
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.
|
|
64
|
+
"@types/react": "19.2.16",
|
|
65
65
|
"eslint": "^10.4.1",
|
|
66
|
-
"tsx": "4.22.
|
|
66
|
+
"tsx": "4.22.4",
|
|
67
67
|
"typescript": "6.0.3",
|
|
68
|
-
"typescript-eslint": "^8.60.
|
|
69
|
-
"vitest": "^4.1.
|
|
68
|
+
"typescript-eslint": "^8.60.1",
|
|
69
|
+
"vitest": "^4.1.8"
|
|
70
70
|
}
|
|
71
71
|
}
|