@hncode/hncode 0.4.2 → 0.5.1
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/README.md +1 -15
- package/README_CN.md +1 -15
- package/bin/hncode +22 -9
- package/package.json +3 -2
- package/src/agent.js +149 -35
- package/src/bootstrap.js +56 -0
- package/src/btw-panel.js +224 -0
- package/src/cache.js +3 -2
- package/src/cell-buffer.js +173 -0
- package/src/ci.js +4 -1
- package/src/clipboard.js +202 -43
- package/src/colors.js +478 -242
- package/src/config.js +409 -40
- package/src/context-bar.js +237 -0
- package/src/doctor.js +518 -0
- package/src/experiments.js +122 -0
- package/src/file-history.js +112 -0
- package/src/file-tree.js +255 -0
- package/src/flush-tick.js +56 -0
- package/src/fork.js +44 -0
- package/src/fx.js +148 -0
- package/src/index.js +123 -11
- package/src/keybindings.js +229 -0
- package/src/llm.js +355 -34
- package/src/lsp.js +401 -0
- package/src/memory-profile.js +247 -0
- package/src/notebook.js +232 -0
- package/src/output-styles.js +183 -0
- package/src/patch.js +248 -0
- package/src/permissions.js +130 -1
- package/src/plan.js +3 -2
- package/src/plugin.js +611 -58
- package/src/prompt-presets.js +1 -1
- package/src/registry-browser.js +31 -13
- package/src/remote.js +20 -2
- package/src/rollout.js +151 -0
- package/src/row-cache.js +148 -0
- package/src/schedule.js +294 -0
- package/src/session-approvals.js +86 -0
- package/src/session-hub.js +1 -0
- package/src/session.js +178 -8
- package/src/settings-schema.js +301 -0
- package/src/shell-safety.js +473 -0
- package/src/side-thread.js +240 -0
- package/src/skills.js +27 -0
- package/src/stream-buffer.js +350 -0
- package/src/subagent-control.js +160 -0
- package/src/teleport.js +260 -0
- package/src/term-caps.js +411 -0
- package/src/term.js +283 -8
- package/src/tool-result.js +264 -236
- package/src/tools/agent-control.js +98 -0
- package/src/tools/agent-swarm.js +11 -2
- package/src/tools/agent.js +35 -1
- package/src/tools/diagnostics.js +175 -0
- package/src/tools/edit.js +25 -10
- package/src/tools/grep.js +62 -20
- package/src/tools/ignore.js +38 -14
- package/src/tools/index.js +10 -0
- package/src/tools/notebook.js +113 -0
- package/src/tools/read-media-file.js +10 -3
- package/src/tools/shell.js +34 -6
- package/src/tui.js +5637 -746
- package/src/web-public/app.js +7 -0
- package/src/web.js +3 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// The segmented context bar: used tokens by content type, then the remainder as one free
|
|
2
|
+
// segment whose right edge carries the usage readout.
|
|
3
|
+
//
|
|
4
|
+
// WHY A BAR AND NOT A NUMBER
|
|
5
|
+
// -------------------------
|
|
6
|
+
// `context: 12% (1.2k/97.7k)` says how full the window is and nothing about what filled
|
|
7
|
+
// it. The bar answers the question people actually have — "why is this getting full" — by
|
|
8
|
+
// making the composition readable: a session that grew from tool output looks different
|
|
9
|
+
// from one that grew from long answers, without reading a single figure.
|
|
10
|
+
//
|
|
11
|
+
// THE SHAPE OF THE ALLOCATION
|
|
12
|
+
// --------------------------
|
|
13
|
+
// Two passes, because plain rounding is wrong in both directions:
|
|
14
|
+
//
|
|
15
|
+
// 1. Every non-zero segment is guaranteed ONE column. Without this, a segment holding
|
|
16
|
+
// 0.3% of the window rounds to zero columns and disappears — and "thinking is a small
|
|
17
|
+
// share" is exactly the fact a user is looking for.
|
|
18
|
+
// 2. The remaining columns are shared proportionally by LARGEST REMAINDER. Rounding each
|
|
19
|
+
// share independently gives a total that is off by one or two, so the bar either falls
|
|
20
|
+
// short of the row or overruns it.
|
|
21
|
+
//
|
|
22
|
+
// THE THREE-STEP LABEL
|
|
23
|
+
// -------------------
|
|
24
|
+
// Each segment says as much about itself as fits, because the segments are often only a
|
|
25
|
+
// few columns wide and there are three different things it could say:
|
|
26
|
+
//
|
|
27
|
+
// 1. `name + value` when the segment is wide enough for both — `ast 4.4k`
|
|
28
|
+
// 2. the name alone when only that fits — `ast`
|
|
29
|
+
// 3. nothing at all when not even the abbreviation does
|
|
30
|
+
//
|
|
31
|
+
// Steps 2 and 3 are not dead ends: every segment's columns are returned as a hit span, so
|
|
32
|
+
// pointing at one prints its full name and value in the row below the bar. That is why step
|
|
33
|
+
// 3 is acceptable — the information stays one pointer away.
|
|
34
|
+
|
|
35
|
+
import { C } from './colors.js';
|
|
36
|
+
|
|
37
|
+
/** The segments, in the order they are drawn. Order is part of the reading: the session
|
|
38
|
+
* grows from left to right in the same sequence the context does. */
|
|
39
|
+
export const SEGMENTS = [
|
|
40
|
+
{ key: 'system', name: 'system prompt', fill: C.ctxSystem },
|
|
41
|
+
{ key: 'prompt', name: 'user messages', fill: C.ctxPrompt },
|
|
42
|
+
{ key: 'assistant', name: 'assistant', fill: C.ctxAssistant },
|
|
43
|
+
{ key: 'thinking', name: 'thinking', fill: C.ctxThinking },
|
|
44
|
+
{ key: 'tools', name: 'tool results', fill: C.ctxTools },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// `labelFor` closes over `compactTokens`, which is declared below — hoisted function
|
|
48
|
+
// declarations make that safe, and keeping the forms next to the metadata is what stops the
|
|
49
|
+
// on-bar label and the hover caption from drifting apart.
|
|
50
|
+
//
|
|
51
|
+
// There is no abbreviation: `ast` says nothing to somebody who does not already know what
|
|
52
|
+
// the bar is, and a full word that does not fit is handled by DROPPING it, not by
|
|
53
|
+
// shortening it into a private code. The three-step degradation in `paintBar` is what makes
|
|
54
|
+
// that affordable — a wide segment gets `assistant 4.4k`, a narrow one gets `assistant`, and
|
|
55
|
+
// a very narrow one gets nothing and relies on the hover caption instead.
|
|
56
|
+
for (const seg of SEGMENTS) {
|
|
57
|
+
seg.labelFor = (tokens) => {
|
|
58
|
+
const v = compactTokens(tokens);
|
|
59
|
+
return { name: seg.name, full: `${seg.name} ${v}`, value: v, title: seg.name };
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** The caption a hovered segment produces: `assistant 4.4k`. */
|
|
63
|
+
/** The caption a hovered segment produces: `assistant 4.4k`. */
|
|
64
|
+
export function segmentCaption(key, tokens) {
|
|
65
|
+
const seg = SEGMENTS.find((s) => s.key === key);
|
|
66
|
+
if (!seg) return '';
|
|
67
|
+
return `${seg.name} ${compactTokens(tokens)}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Give every non-zero segment at least one column, then share the rest proportionally.
|
|
72
|
+
*
|
|
73
|
+
* @param {readonly number[]} values one per segment, plus the free segment last.
|
|
74
|
+
* @param {number} width columns available.
|
|
75
|
+
* @returns {number[]} columns per entry, summing to at most `width`.
|
|
76
|
+
*/
|
|
77
|
+
export function allocateBarColumns(values, width) {
|
|
78
|
+
const used = SEGMENTS
|
|
79
|
+
.map((_, i) => i)
|
|
80
|
+
.filter((i) => (values[i] || 0) > 0);
|
|
81
|
+
if (used.length === 0 || used.length >= width) return shareLargestRemainder(values, width);
|
|
82
|
+
|
|
83
|
+
const minimum = values.map(() => 0);
|
|
84
|
+
for (const i of used) minimum[i] = 1;
|
|
85
|
+
const rest = shareLargestRemainder(values, width - used.length);
|
|
86
|
+
return minimum.map((m, i) => m + (rest[i] || 0));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Proportional split by LARGEST REMAINDER: floor each share, then hand the columns left
|
|
91
|
+
* over to the biggest fractional parts, largest first. This is what makes the parts sum to
|
|
92
|
+
* exactly `width`; rounding each independently does not.
|
|
93
|
+
*/
|
|
94
|
+
function shareLargestRemainder(values, width) {
|
|
95
|
+
const total = values.reduce((a, b) => a + (b || 0), 0);
|
|
96
|
+
if (!(total > 0) || !(width > 0)) return values.map(() => 0);
|
|
97
|
+
|
|
98
|
+
const exact = values.map((v) => ((v || 0) / total) * width);
|
|
99
|
+
const floors = exact.map((x) => Math.floor(x));
|
|
100
|
+
let left = width - floors.reduce((a, b) => a + b, 0);
|
|
101
|
+
|
|
102
|
+
const order = exact
|
|
103
|
+
.map((x, i) => ({ i, frac: x - Math.floor(x) }))
|
|
104
|
+
.sort((a, b) => b.frac - a.frac || a.i - b.i); // stable on index for equal fractions
|
|
105
|
+
for (let k = 0; left > 0; k++, left--) floors[order[k % order.length].i]++;
|
|
106
|
+
return floors;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The bar and its readout, filling exactly `width` columns together.
|
|
111
|
+
*
|
|
112
|
+
* THE READOUT GETS ITS SPACE FIRST, AND THE BAR IS WHAT GIVES UP. Laying it out the other
|
|
113
|
+
* way round — bar across the full row, readout painted on top of the free segment — means
|
|
114
|
+
* the readout disappears the moment the window fills, because a full window has no free
|
|
115
|
+
* segment at all. That is the one moment the figure matters most, and it was the one moment
|
|
116
|
+
* it could not be seen.
|
|
117
|
+
*
|
|
118
|
+
* So the row is split: the readout claims the columns it needs (one gap before it, so the
|
|
119
|
+
* figures do not read as part of a segment), and the bar takes whatever is left. The bar can
|
|
120
|
+
* therefore never reach the readout, at any fill level, and the readout is never truncated
|
|
121
|
+
* into nonsense like `11.7k/97`.
|
|
122
|
+
*
|
|
123
|
+
* @param {Record<string, number>} segments tokens per content type.
|
|
124
|
+
* @param {number} usedTokens total used, driving the readout.
|
|
125
|
+
* @param {number} window the context window, or 0 when unknown.
|
|
126
|
+
* @param {number} width columns to fill.
|
|
127
|
+
* @param {string} [readout] the caller's own wording, e.g. `context: 12% (11.7k/97.7k)`.
|
|
128
|
+
* Preferred over the derived one because it is what the status line already said; when the
|
|
129
|
+
* row is too narrow for it, the derived form takes over, and failing that just the
|
|
130
|
+
* percentage — a readout that is always SOMETHING beats one that is sometimes absent.
|
|
131
|
+
* @returns {{ text: string, hits: Array<{col0: number, col1: number, data: string}> }}
|
|
132
|
+
* `text` occupies exactly `width` columns; `hits` cover the bar's segments only.
|
|
133
|
+
*/
|
|
134
|
+
export function renderContextBar(segments, usedTokens, window, width, readout) {
|
|
135
|
+
if (!(width > 0) || !(window > 0)) return { text: '', hits: [] };
|
|
136
|
+
// Widest form first, then the derived ones, so the row always carries SOMETHING. A
|
|
137
|
+
// half-printed `11.7k/97` is worse than a bare percentage, so each step is a complete
|
|
138
|
+
// alternative rather than a truncation.
|
|
139
|
+
const pct = `${((usedTokens / window) * 100).toFixed(1)}%`;
|
|
140
|
+
const figures = `${compactTokens(usedTokens)}/${compactTokens(window)}`;
|
|
141
|
+
const own = typeof readout === 'string' ? readout : '';
|
|
142
|
+
const forms = [...new Set([own, `${figures} ${pct}`, pct].filter(Boolean))];
|
|
143
|
+
const text = forms.find((f) => f.length + 1 <= width) || '';
|
|
144
|
+
const barWidth = Math.max(0, width - text.length - 1);
|
|
145
|
+
|
|
146
|
+
if (barWidth <= 0) {
|
|
147
|
+
// Too narrow for a bar AND a gap: the readout has the row to itself. Padded to exactly
|
|
148
|
+
// `width` because every row of the frame is, and a short one shifts everything below it.
|
|
149
|
+
return { text: text ? C.white + text.padEnd(width) : ' '.repeat(width), hits: [] };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const free = Math.max(0, window - usedTokens);
|
|
153
|
+
const values = [...SEGMENTS.map((s) => segments[s.key] || 0), free];
|
|
154
|
+
const cols = allocateBarColumns(values, barWidth);
|
|
155
|
+
return paintBar(cols, cols[cols.length - 1] || 0, '', values, text, width);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Paint the allocated columns, then the readout.
|
|
160
|
+
*
|
|
161
|
+
* Each segment carries as much of its own label as fits, measured against the segment's OWN
|
|
162
|
+
* width, so a label can never spill into its neighbour and shift every segment to the right
|
|
163
|
+
* of it.
|
|
164
|
+
*
|
|
165
|
+
* @param {number[]} cols columns per entry, the last being the free segment.
|
|
166
|
+
* @param {number} freeCols the free segment's width, already included in `cols`.
|
|
167
|
+
* @param {string} _unused kept for call-shape stability; the readout no longer lives inside
|
|
168
|
+
* the free segment, so nothing is painted into it here.
|
|
169
|
+
* @param {number[]} values tokens per segment, driving each label.
|
|
170
|
+
* @param {string} readout the text set after the bar, on the page background rather than on
|
|
171
|
+
* any fill — so it is legible at every fill level, including a completely full bar.
|
|
172
|
+
* @param {number} width the whole row, bar plus readout.
|
|
173
|
+
*/
|
|
174
|
+
function paintBar(cols, freeCols, _unused, values, readout, width) {
|
|
175
|
+
let out = '';
|
|
176
|
+
let at = 0; // visible column the next segment starts at
|
|
177
|
+
const hits = [];
|
|
178
|
+
|
|
179
|
+
for (let i = 0; i < SEGMENTS.length; i++) {
|
|
180
|
+
const n = cols[i] || 0;
|
|
181
|
+
if (!n) continue;
|
|
182
|
+
const seg = SEGMENTS[i];
|
|
183
|
+
const label = seg.labelFor(values[i]);
|
|
184
|
+
// One blank column on EACH side of the label. The right one keeps two adjacent short
|
|
185
|
+
// labels from running together (`thinkingtool results`) and making the boundary between
|
|
186
|
+
// them invisible; the left one stops the text from sitting hard against the edge of its
|
|
187
|
+
// own fill, which reads as if the label belonged to the segment before it. The first
|
|
188
|
+
// segment's left column is the row's own margin, so both cost the same.
|
|
189
|
+
const room = n - 2;
|
|
190
|
+
const inner = room >= label.full.length
|
|
191
|
+
? label.full
|
|
192
|
+
: (room >= label.name.length ? label.name : '');
|
|
193
|
+
// `room` columns of label, a blank on the left and one on the right: `n`.
|
|
194
|
+
out += seg.fill + (inner ? ' ' + C.white + inner.padEnd(room, ' ') + ' ' : ' '.repeat(n));
|
|
195
|
+
hits.push({ col0: at, col1: at + n - 1, data: seg.key });
|
|
196
|
+
at += n;
|
|
197
|
+
}
|
|
198
|
+
// The free segment needs SPACES, not just its fill colour: a background colour on its own
|
|
199
|
+
// paints nothing, so the "how much room is left" band vanished the moment the readout moved
|
|
200
|
+
// out of it. It was only ever visible because the readout used to sit on those cells.
|
|
201
|
+
if (freeCols) { out += C.ctxFree + ' '.repeat(freeCols); at += freeCols; }
|
|
202
|
+
|
|
203
|
+
// The readout goes AFTER the bar, on the page background, in the columns the caller
|
|
204
|
+
// reserved for it. `C.reset` first: the last thing the bar emitted was a fill, and a
|
|
205
|
+
// background colour behind the figures would be a band nobody asked for.
|
|
206
|
+
//
|
|
207
|
+
// `at` is the bar's visible width and the row is `width` wide, so the gap is whatever is
|
|
208
|
+
// left minus the figures — at least one column, which is what keeps `11.7k/97` from reading
|
|
209
|
+
// as a continuation of a segment's fill.
|
|
210
|
+
const gap = Math.max(1, width - at - readout.length);
|
|
211
|
+
out += C.reset + ' '.repeat(gap) + C.white + readout;
|
|
212
|
+
|
|
213
|
+
return { text: out + C.reset, hits };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The right-hand readout, stepped down as its segment narrows: the full form, then the
|
|
218
|
+
* percentage alone, then nothing. A half-printed `11.7k/97` is worse than no number.
|
|
219
|
+
*/
|
|
220
|
+
export function formatReadout(used, window, freeCols) {
|
|
221
|
+
const pct = `${((used / window) * 100).toFixed(1)}%`;
|
|
222
|
+
const full = `${compactTokens(used)}/${compactTokens(window)} ${pct}`;
|
|
223
|
+
// The thresholds match the renderer's own condition exactly (`freeCols >= length`). They
|
|
224
|
+
// used to ask for one more, which is how the bar came up a column short: the readout was
|
|
225
|
+
// accepted HERE and then not written THERE.
|
|
226
|
+
if (freeCols >= full.length) return full;
|
|
227
|
+
if (freeCols >= pct.length) return pct;
|
|
228
|
+
return '';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 1200 -> `1.2k`, 97000 -> `97.0k`, 3_400_000 -> `3.4M`. */
|
|
232
|
+
export function compactTokens(n) {
|
|
233
|
+
const v = Number(n) || 0;
|
|
234
|
+
if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
|
|
235
|
+
if (v >= 1e3) return `${(v / 1e3).toFixed(1)}k`;
|
|
236
|
+
return String(Math.round(v));
|
|
237
|
+
}
|