@hncode/hncode 0.5.6 → 0.6.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.
Files changed (56) hide show
  1. package/package.json +37 -38
  2. package/src/agent.js +67 -2
  3. package/src/image-row.js +111 -0
  4. package/src/latex.js +1557 -0
  5. package/src/llm.js +19 -4
  6. package/src/mermaid/ansi.js +55 -0
  7. package/src/mermaid/canvas.js +449 -0
  8. package/src/mermaid/class-style.js +57 -0
  9. package/src/mermaid/css-colors.js +153 -0
  10. package/src/mermaid/diagrams/class.js +289 -0
  11. package/src/mermaid/diagrams/er.js +201 -0
  12. package/src/mermaid/diagrams/flowchart.js +488 -0
  13. package/src/mermaid/diagrams/gitgraph.js +205 -0
  14. package/src/mermaid/diagrams/mindmap.js +101 -0
  15. package/src/mermaid/diagrams/pie.js +104 -0
  16. package/src/mermaid/diagrams/sequence.js +282 -0
  17. package/src/mermaid/diagrams/state.js +292 -0
  18. package/src/mermaid/diagrams/timeline.js +99 -0
  19. package/src/mermaid/graph.js +138 -0
  20. package/src/mermaid/index.js +59 -0
  21. package/src/mermaid/labels.js +339 -0
  22. package/src/mermaid/layout-seq.js +225 -0
  23. package/src/mermaid/layout.js +1454 -0
  24. package/src/mermaid/registry.js +54 -0
  25. package/src/mermaid/source-box.js +102 -0
  26. package/src/mermaid/statements.js +235 -0
  27. package/src/mermaid/types.js +1 -0
  28. package/src/mermaid/width.js +29 -0
  29. package/src/screen-diff.js +512 -0
  30. package/src/screen-paint.js +128 -0
  31. package/src/screen.js +277 -0
  32. package/src/subagent-types.js +136 -147
  33. package/src/term-caps.js +10 -4
  34. package/src/tex/font.js +292 -0
  35. package/src/tex/fonts/LICENSE.txt +11 -0
  36. package/src/tex/fonts/STIX2Math.ttf +0 -0
  37. package/src/tex/index.js +293 -0
  38. package/src/tex/kitty.js +186 -0
  39. package/src/tex/layout.js +446 -0
  40. package/src/tex/parse.js +331 -0
  41. package/src/tex/raster.js +216 -0
  42. package/src/tex/render.js +167 -0
  43. package/src/tex/sixel.js +326 -0
  44. package/src/tex/svg.js +237 -0
  45. package/src/tool-result.js +2 -2
  46. package/src/tools/agent-swarm.js +3 -15
  47. package/src/tools/agent.js +454 -464
  48. package/src/tools/ask-user-question.js +195 -206
  49. package/src/tools/bash.js +39 -3
  50. package/src/tools/edit.js +10 -112
  51. package/src/tools/fetch-url.js +130 -120
  52. package/src/tools/memory.js +80 -88
  53. package/src/tools/read.js +27 -23
  54. package/src/tools/todo.js +8 -13
  55. package/src/tools/validate-args.js +78 -0
  56. package/src/tui.js +1346 -502
package/package.json CHANGED
@@ -1,38 +1,37 @@
1
- {
2
- "name": "@hncode/hncode",
3
- "version": "0.5.6",
4
- "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
- "type": "module",
6
- "main": "src/index.js",
7
- "bin": {
8
- "hncode": "bin/hncode"
9
- },
10
- "files": [
11
- "bin/",
12
- "src/",
13
- "LICENSE",
14
- "README.md",
15
- "README_CN.md",
16
- "package.json"
17
- ],
18
- "scripts": {
19
- "preinstall": "node bin/preinstall.cjs",
20
- "install": "node bin/install.cjs",
21
- "i18n": "node tools/sync-i18n.mjs",
22
- "i18n:check": "node tools/sync-i18n.mjs --check",
23
- "test": "node --test \"test/**/*.test.mjs\""
24
- },
25
- "engines": {
26
- "node": ">=20"
27
- },
28
- "license": "PolyForm-Noncommercial-1.0.0",
29
- "author": "NiceHello666",
30
- "keywords": [
31
- "cli",
32
- "agent",
33
- "coding",
34
- "ai",
35
- "tui"
36
- ],
37
- "dependencies": {}
38
- }
1
+ {
2
+ "name": "@hncode/hncode",
3
+ "version": "0.6.0",
4
+ "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "hncode": "bin/hncode"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "LICENSE",
14
+ "README.md",
15
+ "README_CN.md",
16
+ "package.json"
17
+ ],
18
+ "scripts": {
19
+ "preinstall": "node bin/preinstall.cjs",
20
+ "install": "node bin/install.cjs",
21
+ "i18n": "node tools/sync-i18n.mjs",
22
+ "i18n:check": "node tools/sync-i18n.mjs --check",
23
+ "test": "node --test \"test/**/*.test.mjs\""
24
+ },
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "license": "PolyForm-Noncommercial-1.0.0",
29
+ "author": "NiceHello666",
30
+ "keywords": [
31
+ "cli",
32
+ "agent",
33
+ "coding",
34
+ "ai",
35
+ "tui"
36
+ ]
37
+ }
package/src/agent.js CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { LLM, setToolsList } from './llm.js';
6
6
  import { tools, combinedTools, getTool, llmTools } from './tools/index.js';
7
+ import { validateToolArgs } from './tools/validate-args.js';
7
8
  import { DELEGATION_TOOLS } from './subagent-types.js';
8
9
  import { estimateMessagesTokens, estimateRequestOverhead, estimateTokens, measureToolDefTokens, messageTokens, TokenLedger, forgetMessageTokens } from './term.js';
9
10
  import { runHooks, runPatch, runPatchSync } from './plugin.js';
@@ -72,10 +73,46 @@ const COMPACTION_INSTRUCTION = [
72
73
  '',
73
74
  'Be concise and proportional: a long multi-step task warrants detail, a nearly finished exchange needs only a sentence or two.',
74
75
  '',
75
- 'Respond with text only. Do not call any tools.',
76
+ 'Respond with text only. Do not call any tools. Any tool-call syntax in your answer is discarded and scores the handoff as failed.'
76
77
  ].join('\n');
77
78
 
78
79
  // Trailing note: the model should RESUME, not answer the summary.
80
+ // A summariser can answer with tool-call syntax even when no tools were offered — the format is in its
81
+ // training data, and a model interrupted mid-workflow reaches for it. Nothing was called, so the text is
82
+ // noise, and left in a handoff summary the next turn reads it as a step that happened.
83
+ //
84
+ // The shapes covered are the ones these models actually emit: a special-token block (`<|tool_call|>`), a
85
+ // fenced ```tool_call block, and a bare JSON object that claims a `name` and `arguments`. Only a block that
86
+ // occupies whole lines is removed, so prose that merely mentions calling a tool survives.
87
+ function stripToolCallText(text) {
88
+ if (typeof text !== 'string' || !text) return text || '';
89
+
90
+ // NOTHING WAS CALLED, SO THE BLOCK IS NOISE — but removing it outright can leave nothing at all.
91
+ //
92
+ // A summariser that answers ONLY with a tool call produced no summary: stripping to '' makes the caller
93
+ // treat the compaction as failed, and the fallback there is to drop the history. So the stripped text is
94
+ // measured: if it lost its substance, the original is kept VERBATIM with a note saying what it is. A
95
+ // confusing summary is recoverable; a discarded one is not.
96
+ let out = text;
97
+ // A special-token block: closed form, or an unterminated one running to the end of its line.
98
+ out = out.replace(/<\|tool_call\|>[^\n]*(?:<\|\/tool_call\|>)?/g, '');
99
+ // Fenced block form.
100
+ out = out.replace(/```tool_call[\s\S]*?```/g, '');
101
+ // A bare JSON object on its own lines that declares a tool invocation.
102
+ out = out.replace(/^\s*\{[^\n]*"name"\s*:\s*"[^"]+"[^\n]*"arguments"[^\n]*\}\s*$/gm, '');
103
+ out = out.replace(/\n{3,}/g, '\n\n').trim();
104
+
105
+ if (out.length > 0) return out;
106
+
107
+ // Every remaining character was a tool call: keep the original, marked for what it is.
108
+ return [
109
+ '(The summariser answered with a tool call instead of a summary. No tool was actually run — the text is',
110
+ 'reproduced below unchanged so nothing is lost. Treat it as a summary that was never written.)',
111
+ '',
112
+ text.trim(),
113
+ ].join('\n');
114
+ }
115
+
79
116
  const COMPACTION_CONTINUATION_TEXT = 'Context compaction is complete — continue the work that was in progress when it began.';
80
117
 
81
118
  // Clip to a TOKEN budget, counting non-ASCII as one token per character (the same
@@ -842,7 +879,20 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
842
879
  // reasoning model they can consume the whole output budget — which
843
880
  // returns an EMPTY summary, and an empty summary is what turns a
844
881
  // compaction into a silent loss of history.
845
- try { summary = await llm.requestText(summaryPrompt, { noReasoning: true, noTools: true }); } catch { summary = ''; }
882
+ try {
883
+ // A DECLARATION IS NEEDED FOR THE REFUSAL.
884
+ //
885
+ // `tool_choice: 'none'` is rejected by both providers unless a tool list accompanies it, and the
886
+ // summariser is a fresh instance with none. One inert definition is declared so the refusal can be
887
+ // expressed: the model then cannot produce a tool call, rather than being asked not to.
888
+ llm._tools = [{
889
+ name: 'noop',
890
+ description: 'Takes no action. Present only so the request can refuse tool use.',
891
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
892
+ }];
893
+ const raw = await llm.requestText(summaryPrompt, { noReasoning: true, toolChoice: 'none' });
894
+ summary = stripToolCallText(raw);
895
+ } catch { summary = ''; }
846
896
  this._summaryLlm = null;
847
897
  }
848
898
  // Interrupted mid-summary: do NOT trim. Aborting yields an empty summary, and
@@ -1123,6 +1173,21 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
1123
1173
  return { ok: false, result, editDiff: null, media: false };
1124
1174
  }
1125
1175
 
1176
+ // THE ARGUMENTS MUST MATCH THE TOOL'S OWN SCHEMA.
1177
+ //
1178
+ // An unknown parameter used to be accepted in silence, and the model had no way to learn that the
1179
+ // argument it took trouble to compute was thrown away: it sends `line_offset` to a tool that takes
1180
+ // `start_line`, gets a plausible answer computed from defaults, and concludes the tool is broken.
1181
+ // Refusing the call names the key and lists the accepted ones, which is the only thing that stops the
1182
+ // same mistake being retried with a different spelling.
1183
+ const argError = validateToolArgs(tc.args, tool.parameters);
1184
+ if (argError) {
1185
+ result = `Error: ${tc.name}: ${argError}`;
1186
+ this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
1187
+ return { ok: false, result, editDiff: null, media: false };
1188
+ }
1189
+
1190
+
1126
1191
  // SHELL HOOK: PreToolUse. A non-zero exit blocks the tool, and its
1127
1192
  // message goes back to the model in place of the tool result — that is
1128
1193
  // the whole point of a pre-hook (e.g. refuse a write that fails a lint
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Where a bitmap sits in a text frame.
3
+ *
4
+ * The TUI composes a frame as an array of strings and writes only the rows that changed, each addressed
5
+ * absolutely. A bitmap breaks that model twice: it is not text, so it cannot live in a row's bytes, and it
6
+ * is more than one row tall, so the rows under it must stay blank or the next repaint paints over it.
7
+ *
8
+ * So a row that carries an image is written as a sentinel — a single private-use character plus the
9
+ * payload — and this module turns a frame into the bytes that place it: address the row, emit the
10
+ * sequence, then remember which rows the image covers so the following text rows can be skipped.
11
+ *
12
+ * A SIXEL PAYLOAD DRAWN INLINE IS JUST CHARACTERS. That is why the first attempt showed nothing: the
13
+ * terminal consumed the bytes as text, scrolled them, and the formula never appeared. Placement needs a
14
+ * cursor move first, which is what `\x1b[<row>;<col>H` does.
15
+ */
16
+
17
+ /** Marks a row whose content is a placed bitmap. A private-use code point no text will contain. */
18
+ export const IMAGE_MARK = '\u0001IMG:';
19
+
20
+ /**
21
+ * Separates the payload from the source that produced it.
22
+ *
23
+ * NOT A NUL BYTE. `SPIN_PLACEHOLDER` in the transcript renderer is `\u0000`, and every row is run through a
24
+ * substitution that replaces it with the spinner frame — so a tag built on it arrived as `⠋SRC:…` and the
25
+ * source was unreadable. `\u0001` is what `IMAGE_MARK` already uses and nothing substitutes it.
26
+ */
27
+ export const SOURCE_TAG = '\u0001SRC:';
28
+
29
+
30
+ /**
31
+ * Encode an image row: the mark, then the payload and its geometry, base64-free but length-prefixed so a
32
+ * payload containing the mark cannot be misparsed.
33
+ *
34
+ * `source` is optional and travels with the row for pictures that can be REGENERATED: a formula that straddles
35
+ * the top of the window must be redrawn from its first visible row, and only its TeX can produce that raster.
36
+ * It is stored last, after the sequence, so a row written before this field existed still parses.
37
+ *
38
+ * @param {string} sequence The terminal sequence to emit.
39
+ * @param {number} rows Rows of text the image covers.
40
+ * @param {number} cols Columns the image covers.
41
+ * @param {string} [source] What produced the sequence, for a picture that may need cropping later.
42
+ */
43
+ export function encodeImageRow(sequence, rows, cols, source) {
44
+ const head = IMAGE_MARK + rows + ':' + cols + ':';
45
+ return typeof source === 'string' && source !== ''
46
+ ? head + sequence + SOURCE_TAG + source
47
+ : head + sequence;
48
+ }
49
+
50
+ /**
51
+ * True when a composed row is an image rather than text.
52
+ *
53
+ * The row may carry the transcript's indent prefix — `messageLines` gives every line one — so the marker is
54
+ * not necessarily at index 0. Matching it anywhere in the first few characters is what makes this work
55
+ * without the row builder having to special-case images.
56
+ */
57
+ export function isImageRow(line) {
58
+ if (typeof line !== 'string') return false;
59
+ const at = line.indexOf(IMAGE_MARK);
60
+ return at >= 0 && at < 8 && /^\s*$/.test(line.slice(0, at));
61
+ }
62
+
63
+ /**
64
+ * Split an image row back into its parts.
65
+ * @returns {{sequence: string, rows: number, cols: number, source: string}|null}
66
+ */
67
+ export function parseImageRow(line) {
68
+ if (!isImageRow(line)) return null;
69
+ const at = line.indexOf(IMAGE_MARK);
70
+ const rest = line.slice(at + IMAGE_MARK.length);
71
+ const firstColon = rest.indexOf(':');
72
+ if (firstColon < 0) return null;
73
+ const secondColon = rest.indexOf(':', firstColon + 1);
74
+ if (secondColon < 0) return null;
75
+ const rows = Number(rest.slice(0, firstColon));
76
+ const cols = Number(rest.slice(firstColon + 1, secondColon));
77
+ if (!Number.isFinite(rows) || !Number.isFinite(cols)) return null;
78
+ let body = rest.slice(secondColon + 1);
79
+ let source = '';
80
+ const tag = body.lastIndexOf(SOURCE_TAG);
81
+ if (tag >= 0) {
82
+ source = body.slice(tag + SOURCE_TAG.length);
83
+ body = body.slice(0, tag);
84
+ }
85
+ return { rows, cols, sequence: body, source };
86
+ }
87
+
88
+
89
+ /**
90
+ * The bytes that place one image at its row, and how many rows it covers.
91
+ *
92
+ * @param {string} line The image row.
93
+ * @param {number} rowIndex 0-based screen row the row was composed for.
94
+ * @param {string} reset SGR reset to restore after the sequence.
95
+ * @returns {{bytes: string, covered: number}|null}
96
+ */
97
+ export function imageBytes(line, rowIndex, reset) {
98
+ const parsed = parseImageRow(line);
99
+ if (!parsed) return null;
100
+
101
+ // DEC PRIVATE MODE 80 IS THE SIXEL SCROLLING MODE.
102
+ //
103
+ // With it enabled (the default) a terminal that draws a bitmap pushes the text below it down — and in a
104
+ // full-screen TUI every bitmap therefore scrolls the whole interface. That is what produced the repeated
105
+ // composer: each formula shoved the frame up and the next paint drew it again further down.
106
+ //
107
+ // `?80l` turns the mode off for the duration of the draw and `?80h` restores it. dsh-tui wraps its own
108
+ // placement the same way.
109
+ const bytes = `\x1b[?80l\x1b[${rowIndex + 1};1H${reset}${parsed.sequence}\x1b[?80h`;
110
+ return { bytes, covered: parsed.rows };
111
+ }