@hncode/hncode 0.4.1 → 0.5.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/README.md +3 -16
- package/README_CN.md +1 -15
- package/bin/hncode +22 -9
- package/package.json +3 -2
- package/src/agent.js +145 -33
- 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 +175 -30
- package/src/config.js +376 -34
- package/src/context-bar.js +237 -0
- package/src/doctor.js +320 -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/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 +691 -43
- package/src/prompt-presets.js +1 -1
- package/src/registry-browser.js +317 -0
- package/src/remote.js +289 -0
- package/src/rollout.js +151 -0
- package/src/row-cache.js +143 -0
- package/src/schedule.js +294 -0
- package/src/session-approvals.js +86 -0
- package/src/session.js +178 -8
- 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/tasks-browser.js +54 -11
- package/src/teleport.js +260 -0
- package/src/term-caps.js +411 -0
- package/src/term.js +283 -8
- 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/bash.js +17 -3
- 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 +134 -0
- package/src/tui.js +16682 -11665
- package/src/web-public/app.css +22 -9
- package/src/web-public/app.js +107 -54
- package/src/web-public/home.css +3 -3
- package/src/web-public/login.css +1 -1
package/src/btw-panel.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// The /btw box — a bordered panel docked ABOVE the composer, the way kimi's
|
|
2
|
+
// BtwPanelComponent is (see its `mount`, which adds the panel to
|
|
3
|
+
// `btwPanelContainer` and sets `editor.connectedAbove = true`).
|
|
4
|
+
//
|
|
5
|
+
// Why a docked box rather than a full-screen panel: the side conversation is
|
|
6
|
+
// something you have WHILE working, and the composer stays the place you type. In
|
|
7
|
+
// kimi the composer input IS the way to ask a follow-up — `sendUserInput` feeds the
|
|
8
|
+
// same panel — so the box sits directly above it and the editor is where you keep
|
|
9
|
+
// typing. A full-screen takeover hides the conversation being asked about, which is
|
|
10
|
+
// precisely the context a side question is about.
|
|
11
|
+
//
|
|
12
|
+
// Shape, matching kimi's renderer:
|
|
13
|
+
//
|
|
14
|
+
// ╭ BTW ─ Esc close ─────────────────────────────╮
|
|
15
|
+
// │ Q: what does the --retries flag do? │
|
|
16
|
+
// │ The `--retries` flag controls how many… │
|
|
17
|
+
// │ │
|
|
18
|
+
// │ Q: and the timeout flag? │
|
|
19
|
+
// │ It sets how long a run may take… │
|
|
20
|
+
// ╰──────────────────────────────────────────────╯
|
|
21
|
+
//
|
|
22
|
+
// Differences from kimi, all deliberate:
|
|
23
|
+
// * turns are held in the SESSION side thread, not here, so /btw clear and the
|
|
24
|
+
// accumulated view have one source of truth;
|
|
25
|
+
// * the height cap is a fraction of the terminal (kimi: a third) but the panel
|
|
26
|
+
// GROWS to the largest size it has needed, so it does not jitter on every token.
|
|
27
|
+
|
|
28
|
+
import { visualWidth } from './term.js';
|
|
29
|
+
import { C } from './colors.js';
|
|
30
|
+
|
|
31
|
+
/** Rows kimi reserves as the floor for the box, so it is never a one-line sliver. */
|
|
32
|
+
export const MIN_PANEL_LINES = 3;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The box's height in rows, given the terminal height.
|
|
36
|
+
*
|
|
37
|
+
* Mirrors kimi's `collapsedBodyLimit`: at most a THIRD of the terminal, at least
|
|
38
|
+
* MIN_PANEL_LINES. Returns the TOTAL rows the box occupies (borders included), and
|
|
39
|
+
* 0 when there is nothing to show so the caller can skip it entirely.
|
|
40
|
+
*/
|
|
41
|
+
export function btwPanelHeight(state, terminalRows, width) {
|
|
42
|
+
const panel = state && state.btwPanel;
|
|
43
|
+
if (!panel || !panel.turns || !panel.turns.length) return 0;
|
|
44
|
+
const rows = Number.isFinite(terminalRows) && terminalRows > 0 ? terminalRows : 24;
|
|
45
|
+
// At most a THIRD of the terminal (kimi's rule), floor of MIN_PANEL_LINES, and the
|
|
46
|
+
// rest of the screen keeps the transcript visible.
|
|
47
|
+
const maxBody = Math.max(MIN_PANEL_LINES, Math.floor(rows / 3)) - 1;
|
|
48
|
+
const inner = Math.max(1, (width | 0) - 4);
|
|
49
|
+
const needed = btwBodyLines(panel, inner);
|
|
50
|
+
// `_btwMinBody` grows monotonically, like kimi's `minBodyLines`: a box that shrank
|
|
51
|
+
// as a streamed answer reflowed would make the screen jump on most tokens.
|
|
52
|
+
const mark = Math.max(Number(panel._btwMinBody) || 0, needed);
|
|
53
|
+
if (panel && typeof panel === 'object') panel._btwMinBody = mark;
|
|
54
|
+
const want = Math.max(1, Math.min(maxBody, mark));
|
|
55
|
+
return want + 2; // the two border rows
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* How many content rows the box has ever needed.
|
|
60
|
+
*
|
|
61
|
+
* kimi keeps `minBodyLines` and grows it monotonically for the same reason: a box
|
|
62
|
+
* that resized on every streamed token would make the whole screen jump. Seeding it
|
|
63
|
+
* from this means the height only ever changes when the CONTENT needs more room.
|
|
64
|
+
*/
|
|
65
|
+
export function btwBodyLines(panel, width) {
|
|
66
|
+
if (!panel || !panel.turns || !panel.turns.length) return 0;
|
|
67
|
+
let n = 0;
|
|
68
|
+
for (const t of panel.turns) {
|
|
69
|
+
if (n > 0) n += 1; // the blank line between turns
|
|
70
|
+
n += wrapCount(`Q: ${t.prompt}`, width);
|
|
71
|
+
const answer = String(t.answer || '');
|
|
72
|
+
if (answer.trim()) {
|
|
73
|
+
for (const line of answer.split('\n')) n += Math.max(1, wrapCount(line, width));
|
|
74
|
+
} else if (t.thinking && t.thinking.trim()) {
|
|
75
|
+
n += 1; // the thinking preview
|
|
76
|
+
} else if (!t.error) {
|
|
77
|
+
n += 1; // "Waiting for answer…"
|
|
78
|
+
}
|
|
79
|
+
if (t.error) n += wrapCount(t.error, width);
|
|
80
|
+
// The blinking caret row. It MUST be counted here or the box is one row short and
|
|
81
|
+
// the last content line is clipped off.
|
|
82
|
+
if (t.phase === 'running') n += 1;
|
|
83
|
+
}
|
|
84
|
+
return n;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** How many display rows a string needs at `width`. Never returns 0. */
|
|
88
|
+
function wrapCount(text, width) {
|
|
89
|
+
const w = Math.max(1, width);
|
|
90
|
+
const s = String(text == null ? '' : text);
|
|
91
|
+
if (!s.length) return 1;
|
|
92
|
+
let rows = 0;
|
|
93
|
+
for (const line of s.split('\n')) {
|
|
94
|
+
const vis = visualWidth(line);
|
|
95
|
+
rows += Math.max(1, Math.ceil(vis / w));
|
|
96
|
+
}
|
|
97
|
+
return rows;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Clip a string to `width` display columns, adding `…` when it was cut. */
|
|
101
|
+
function clip(text, width) {
|
|
102
|
+
const s = String(text == null ? '' : text);
|
|
103
|
+
if (visualWidth(s) <= width) return s;
|
|
104
|
+
let out = '';
|
|
105
|
+
let w = 0;
|
|
106
|
+
for (const ch of s) {
|
|
107
|
+
const cw = visualWidth(ch);
|
|
108
|
+
if (w + cw > Math.max(0, width - 1)) break;
|
|
109
|
+
out += ch;
|
|
110
|
+
w += cw;
|
|
111
|
+
}
|
|
112
|
+
return out + '…';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Render the box: `{ lines }`, one entry per SCREEN row including the borders.
|
|
117
|
+
*
|
|
118
|
+
* `state.btwScroll` counts rows up from the bottom of the content, as an offset from
|
|
119
|
+
* the tail — the same convention the transcript uses, so ↑/↓ can share the code.
|
|
120
|
+
*/
|
|
121
|
+
export function renderBtwPanel(state, width, opts = {}) {
|
|
122
|
+
const panel = state && state.btwPanel;
|
|
123
|
+
if (!panel || !panel.turns || !panel.turns.length) return [];
|
|
124
|
+
const w = Math.max(8, width | 0);
|
|
125
|
+
const inner = Math.max(1, w - 4); // '│ ' + content + ' │'
|
|
126
|
+
const terminalRows = opts.terminalRows;
|
|
127
|
+
const height = btwPanelHeight(state, terminalRows, w);
|
|
128
|
+
const bodyRows = Math.max(1, height - 2);
|
|
129
|
+
|
|
130
|
+
// ---- the content rows, in order ----
|
|
131
|
+
const content = [];
|
|
132
|
+
for (let i = 0; i < panel.turns.length; i++) {
|
|
133
|
+
const t = panel.turns[i];
|
|
134
|
+
// Every entry is a `{ l, color }` ROW. The blank separator between turns must be
|
|
135
|
+
// one too: a bare '' pushed in its place rendered as `undefined` because the
|
|
136
|
+
// renderer reads `row.l`.
|
|
137
|
+
if (i > 0) content.push({ l: '', color: C.white });
|
|
138
|
+
for (const l of wrapLine(`Q: ${t.prompt}`, inner)) content.push({ l, color: C.cyan });
|
|
139
|
+
const answer = String(t.answer || '');
|
|
140
|
+
if (answer.trim()) {
|
|
141
|
+
for (const raw of answer.split('\n')) {
|
|
142
|
+
// A blank line inside an answer stays blank; otherwise each line is wrapped on
|
|
143
|
+
// its own so a long paragraph does not reflow into the previous one.
|
|
144
|
+
if (!raw.trim()) { content.push({ l: '', color: C.white }); continue; }
|
|
145
|
+
for (const l of wrapLine(raw, inner)) content.push({ l, color: C.white });
|
|
146
|
+
}
|
|
147
|
+
} else if (t.thinking && t.thinking.trim()) {
|
|
148
|
+
content.push({ l: clip(` …${t.thinking.trim().split('\n').slice(-1)[0]}`, inner), color: C.gray });
|
|
149
|
+
} else if (!t.error) {
|
|
150
|
+
content.push({ l: 'Waiting for answer…', color: C.gray });
|
|
151
|
+
}
|
|
152
|
+
if (t.error) for (const l of wrapLine(t.error, inner)) content.push({ l, color: C.red });
|
|
153
|
+
if (t.phase === 'running') content.push({ l: '▍', color: C.gray });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---- scroll: offset from the tail, like the transcript ----
|
|
157
|
+
|
|
158
|
+
// ---- scroll: offset from the tail, like the transcript ----
|
|
159
|
+
const maxTop = Math.max(0, content.length - bodyRows);
|
|
160
|
+
const scroll = Math.max(0, Math.min(maxTop, Number(state.btwScroll) || 0));
|
|
161
|
+
const start = Math.max(0, content.length - bodyRows - scroll);
|
|
162
|
+
const shown = content.slice(start, start + bodyRows);
|
|
163
|
+
while (shown.length < bodyRows) shown.push({ l: '', color: C.white });
|
|
164
|
+
|
|
165
|
+
// ---- the frame ----
|
|
166
|
+
const out = [];
|
|
167
|
+
const hint = maxTop > 0 ? 'Esc close · ↑↓ scroll ' : 'Esc close ';
|
|
168
|
+
const title = ' BTW ';
|
|
169
|
+
const head = `${title}${'─'.repeat(Math.max(1, w - 2 - visualWidth(title) - visualWidth(hint) - 1))} ${hint}`;
|
|
170
|
+
// Every border segment is painted on its own, so the interior can carry the text
|
|
171
|
+
// colour while the frame keeps the border colour. Painting the whole line and
|
|
172
|
+
// patch-substituting the corners (as an earlier version did) left the colour escape
|
|
173
|
+
// codes embedded in the output.
|
|
174
|
+
out.push(col('╭', C.border) + col(fitBorder(head, w - 2), C.border) + col('╮', C.border));
|
|
175
|
+
for (const row of shown) {
|
|
176
|
+
const padded = row.l + ' '.repeat(Math.max(0, inner - visualWidth(row.l)));
|
|
177
|
+
out.push(col('│', C.border) + ' ' + col(padded, row.color) + ' ' + col('│', C.border));
|
|
178
|
+
}
|
|
179
|
+
const above = start > 0 ? `▲ ${start} more` : '';
|
|
180
|
+
const below = scroll > 0 ? `▼ ${scroll} more` : '';
|
|
181
|
+
const tailHint = [above, below].filter(Boolean).join(' ') || 'Esc close';
|
|
182
|
+
// The bottom border reads `╰─ Esc close ─────…────╯`: the hint sits in the middle of
|
|
183
|
+
// the rule. The two corners take a column each, so the MIDDLE segment must be exactly
|
|
184
|
+
// `w - 2` wide — `─ ` + hint + ` ` + fill.
|
|
185
|
+
const foot = clip(tailHint, Math.max(1, w - 6));
|
|
186
|
+
const mid = `─ ${foot} `;
|
|
187
|
+
const footFill = Math.max(0, w - 2 - visualWidth(mid));
|
|
188
|
+
out.push(col('╰', C.border) + col(mid + '─'.repeat(footFill), C.border) + col('╯', C.border));
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Paint `s`, resetting after it so the next segment starts clean. */
|
|
193
|
+
function col(s, color) {
|
|
194
|
+
return color ? color + s + C.reset : s;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
/** Pad or clip a border segment to exactly `width` columns. */
|
|
199
|
+
function fitBorder(s, width) {
|
|
200
|
+
const w = Math.max(0, width);
|
|
201
|
+
if (visualWidth(s) === w) return s;
|
|
202
|
+
if (visualWidth(s) > w) return clip(s, w);
|
|
203
|
+
return s + '─'.repeat(w - visualWidth(s));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Wrap one logical line into display rows of at most `width` columns. */
|
|
207
|
+
export function wrapLine(text, width) {
|
|
208
|
+
const w = Math.max(1, width);
|
|
209
|
+
const s = String(text == null ? '' : text);
|
|
210
|
+
if (!s.length) return [''];
|
|
211
|
+
const out = [];
|
|
212
|
+
let cur = '';
|
|
213
|
+
let curW = 0;
|
|
214
|
+
for (const ch of s) {
|
|
215
|
+
const cw = visualWidth(ch);
|
|
216
|
+
if (curW + cw > w) { out.push(cur); cur = ''; curW = 0; }
|
|
217
|
+
cur += ch;
|
|
218
|
+
curW += cw;
|
|
219
|
+
}
|
|
220
|
+
out.push(cur);
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
package/src/cache.js
CHANGED
|
@@ -29,7 +29,7 @@ export const MAX_BREAKPOINTS = 4;
|
|
|
29
29
|
export const MIN_CACHEABLE_TOKENS = 1024;
|
|
30
30
|
|
|
31
31
|
export function supportsCaching(protocol) {
|
|
32
|
-
return protocol === 'anthropic' || protocol === 'openai';
|
|
32
|
+
return protocol === 'anthropic' || protocol === 'openai' || protocol === 'responses';
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
// Index (into `messages`) of the last message that belongs to the STABLE prefix.
|
|
@@ -125,6 +125,7 @@ export function withOpenAiCache(body, opts = {}) {
|
|
|
125
125
|
export function applyPromptCache(protocol, body, opts = {}) {
|
|
126
126
|
if (!opts.enabled) return body;
|
|
127
127
|
if (protocol === 'anthropic') return withAnthropicCache(body, opts);
|
|
128
|
-
|
|
128
|
+
// Responses API accepts the same stable `prompt_cache_key` as chat/completions.
|
|
129
|
+
if (protocol === 'openai' || protocol === 'responses') return withOpenAiCache(body, opts);
|
|
129
130
|
return body;
|
|
130
131
|
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// A minimal retained-mode cell layer, modelled on OpenTUI's OptimizedBuffer.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS
|
|
4
|
+
// ---------------
|
|
5
|
+
// The picker used to be painted as a finished ANSI STRING assigned over a whole
|
|
6
|
+
// frame row (`lines[row] = boxRow`). That is not an overlay, it is a replacement:
|
|
7
|
+
// every column the card's padding happened to occupy was erased, so the transcript
|
|
8
|
+
// beside the card disappeared — a row of `11111111111111` rendered as
|
|
9
|
+
// ` |111111|`.
|
|
10
|
+
//
|
|
11
|
+
// OpenTUI gets this right because it never builds strings. It keeps a 2-D buffer of
|
|
12
|
+
// cells, paints the dialog into its OWN buffer, and composites that buffer onto the
|
|
13
|
+
// scene with `drawFrameBuffer`. The rule that makes it work is in `blendCells`:
|
|
14
|
+
//
|
|
15
|
+
// const preserveChar = (charIsDefaultSpace and destNotZero and
|
|
16
|
+
// destNotDefaultSpace and destWidthIsOne);
|
|
17
|
+
// const finalChar = if (preserveChar) destCell.char else overlayCell.char;
|
|
18
|
+
//
|
|
19
|
+
// An overlay cell that is a BLANK SPACE does not overwrite what is underneath — it
|
|
20
|
+
// keeps the destination's character. That single rule is the difference between a
|
|
21
|
+
// floating window and a full-screen takeover.
|
|
22
|
+
//
|
|
23
|
+
// hncode still renders its body as ANSI strings (rewriting all of it would be a much
|
|
24
|
+
// larger change), so this module does one job: turn a row of that text into cells,
|
|
25
|
+
// composite an overlay row onto it cell-by-cell using OpenTUI's rule, and turn the
|
|
26
|
+
// result back into an ANSI string. Zero dependencies, like the rest of hncode.
|
|
27
|
+
|
|
28
|
+
import { charWidth } from './term.js';
|
|
29
|
+
|
|
30
|
+
// A cell in a layer. `blank` marks a cell the overlay does not paint — the equivalent
|
|
31
|
+
// of OpenTUI's DEFAULT_SPACE_CHAR combined with a transparent alpha. Those cells
|
|
32
|
+
// defer to whatever is underneath instead of erasing it.
|
|
33
|
+
export function cell(ch, style, blank = false) {
|
|
34
|
+
return { ch, style: style || '', blank };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const RESET = '\x1b[0m';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The cell that follows a double-width glyph, occupying its second column.
|
|
41
|
+
*
|
|
42
|
+
* A terminal does not advance one column per CHARACTER, it advances one column per
|
|
43
|
+
* DISPLAY CELL. `你好` is two characters but eight columns; `😀` is one character and
|
|
44
|
+
* two columns. A layer indexed by character therefore drifts out of step with the
|
|
45
|
+
* screen as soon as it contains anything wide, and every glyph after it lands one
|
|
46
|
+
* or more columns to the left of where the terminal will actually draw it — which is
|
|
47
|
+
* how backdrop text ended up showing through the middle of an opaque dialog.
|
|
48
|
+
*
|
|
49
|
+
* The continuation cell holds an empty `ch`, so rendering emits the wide glyph once
|
|
50
|
+
* and the terminal supplies the second column itself. This is the same accounting
|
|
51
|
+
* OpenTUI does with `encodedCharWidth`.
|
|
52
|
+
*/
|
|
53
|
+
const WIDE_CONT = { ch: '', style: '', blank: false, cont: true };
|
|
54
|
+
|
|
55
|
+
/** A cell that defers to the layer below it. */
|
|
56
|
+
export const BLANK = cell(' ', '', true);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Split an ANSI string into cells, ONE PER DISPLAY COLUMN.
|
|
60
|
+
*
|
|
61
|
+
* A wide (CJK) glyph is ONE cell followed by a continuation cell spanning its second
|
|
62
|
+
* column, so `cells.length` always equals `visualWidth(str)` and index N is always
|
|
63
|
+
* screen column N. Indexing by code point instead — which this used to do — silently
|
|
64
|
+
* desynchronised the layer from the screen for any row containing wide text: the
|
|
65
|
+
* layer came out shorter than the columns it had to cover, so the tail of the row was
|
|
66
|
+
* never overwritten and the transcript underneath showed through the dialog.
|
|
67
|
+
*
|
|
68
|
+
* Zero-width characters (combining marks, joiners) occupy no column and are dropped.
|
|
69
|
+
*/
|
|
70
|
+
export function parseCells(s) {
|
|
71
|
+
const str = String(s == null ? '' : s);
|
|
72
|
+
const cells = [];
|
|
73
|
+
let style = '';
|
|
74
|
+
let i = 0;
|
|
75
|
+
while (i < str.length) {
|
|
76
|
+
if (str[i] === '\x1b') {
|
|
77
|
+
const m = /^\x1b\[[0-9;?]*[a-zA-Z]/.exec(str.slice(i))
|
|
78
|
+
|| /^\x1b\[[0-9;?]* [a-zA-Z]/.exec(str.slice(i));
|
|
79
|
+
if (m) {
|
|
80
|
+
// A reset ends the current style rather than becoming part of the next one. Both
|
|
81
|
+
// spellings matter: `renderCells` emits `\x1b[0m`, but a row that came from
|
|
82
|
+
// elsewhere — a shell escape, a plugin, a hand-built string — can carry the bare
|
|
83
|
+
// `\x1b[m`, and treating that as an ordinary attribute left the old style attached
|
|
84
|
+
// to the next cell. The stale `48;…` then survived every later edit and the row
|
|
85
|
+
// never quite matched itself.
|
|
86
|
+
if (m[0] === '\x1b[0m' || m[0] === '\x1b[m') style = '';
|
|
87
|
+
else style += m[0];
|
|
88
|
+
i += m[0].length;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const cp = str.codePointAt(i);
|
|
93
|
+
const ch = String.fromCodePoint(cp);
|
|
94
|
+
const step = cp > 0xffff ? 2 : 1;
|
|
95
|
+
const cw = charWidth(ch);
|
|
96
|
+
if (cw === 0) { i += step; continue; } // combining mark: no column of its own
|
|
97
|
+
cells.push(cell(ch, style));
|
|
98
|
+
if (cw === 2) cells.push({ ...WIDE_CONT, style }); // its second column
|
|
99
|
+
i += step;
|
|
100
|
+
}
|
|
101
|
+
return cells;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Render cells back to an ANSI string.
|
|
106
|
+
*
|
|
107
|
+
* A RESET is emitted before each new style run, so the output is IDEMPOTENT: parsing
|
|
108
|
+
* it again reproduces exactly the styles it came from. Without the reset the codes
|
|
109
|
+
* accumulate — a row that was dimmed, re-parsed and re-rendered grew a fresh `\x1b[2m`
|
|
110
|
+
* on every pass, and after a few frames a cell's "style" was hundreds of repeated
|
|
111
|
+
* escape codes. That is invisible in a stripped-text check and eventually slows the
|
|
112
|
+
* whole render path down.
|
|
113
|
+
*/
|
|
114
|
+
export function renderCells(cells) {
|
|
115
|
+
let out = '';
|
|
116
|
+
let cur = null;
|
|
117
|
+
for (const c of cells) {
|
|
118
|
+
// A wide glyph's continuation cell carries no character of its own: the glyph
|
|
119
|
+
// before it already occupies both columns on screen, so emitting anything here
|
|
120
|
+
// would shift the rest of the row one column to the right.
|
|
121
|
+
if (c.cont) continue;
|
|
122
|
+
if (c.style !== cur) {
|
|
123
|
+
// Close the previous run before opening the next, so neither leaks into the other.
|
|
124
|
+
out += c.style === '' ? RESET : RESET + c.style;
|
|
125
|
+
cur = c.style;
|
|
126
|
+
}
|
|
127
|
+
out += c.ch;
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* OpenTUI's `blendCells`, reduced to the one rule that matters here.
|
|
134
|
+
*
|
|
135
|
+
* An overlay cell that is BLANK keeps the destination's character — and, since the
|
|
136
|
+
* destination has already been dimmed by the time the layer goes on top, that
|
|
137
|
+
* character arrives dimmed. Everything else in the overlay is opaque and wins.
|
|
138
|
+
*/
|
|
139
|
+
export function blendCell(overlay, dest) {
|
|
140
|
+
if (!overlay.blank) return overlay;
|
|
141
|
+
// A continuation cell belongs to the wide glyph in the cell before it. If the
|
|
142
|
+
// overlay put a wide glyph here, that glyph owns both columns, so the
|
|
143
|
+
// destination's second half must not survive independently of the first.
|
|
144
|
+
if (overlay.cont) return overlay;
|
|
145
|
+
if (dest && dest.ch && dest.ch !== ' ' && !dest.cont) return dest;
|
|
146
|
+
return overlay;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Composite an overlay row onto a base row at column `destX`, cell by cell, using
|
|
151
|
+
* `blendCell`. This is `drawFrameBuffer` for a single row.
|
|
152
|
+
*
|
|
153
|
+
* `overlay` is a cell array (build one with `parseCells` and mark the cells that must
|
|
154
|
+
* stay transparent with `BLANK`), or a plain string for the common case of an opaque
|
|
155
|
+
* overlay. The base row is a string and is never mutated — each call parses its own
|
|
156
|
+
* copy, so compositing several rows cannot make one bleed into the next.
|
|
157
|
+
*/
|
|
158
|
+
export function drawFrameBuffer(baseRow, overlay, destX) {
|
|
159
|
+
const base = parseCells(baseRow);
|
|
160
|
+
const layer = typeof overlay === 'string' ? parseCells(overlay) : overlay;
|
|
161
|
+
for (let i = 0; i < layer.length; i++) {
|
|
162
|
+
const at = destX + i;
|
|
163
|
+
if (at < 0) continue;
|
|
164
|
+
if (at >= base.length) {
|
|
165
|
+
// Past the end of the base row: pad so the overlay still lands.
|
|
166
|
+
while (base.length < at) base.push(BLANK);
|
|
167
|
+
base.push(blendCell(layer[i], null));
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
base[at] = blendCell(layer[i], base[at]);
|
|
171
|
+
}
|
|
172
|
+
return renderCells(base);
|
|
173
|
+
}
|
package/src/ci.js
CHANGED
|
@@ -49,7 +49,7 @@ export function isValidFormat(f) {
|
|
|
49
49
|
// files — files the run wrote/edited (from tool ARGS, best-effort)
|
|
50
50
|
// error — a fatal error message, when the run failed outright
|
|
51
51
|
// stopped — the turn was interrupted
|
|
52
|
-
export function buildResult({ reply, toolCalls, files, error, stopped, durationMs, model, sessionId }) {
|
|
52
|
+
export function buildResult({ reply, toolCalls, files, error, stopped, durationMs, model, sessionId, timedOut }) {
|
|
53
53
|
const hasReply = typeof reply === 'string' && reply.trim().length > 0;
|
|
54
54
|
const ok = !error && !stopped && hasReply;
|
|
55
55
|
const result = {
|
|
@@ -63,6 +63,9 @@ export function buildResult({ reply, toolCalls, files, error, stopped, durationM
|
|
|
63
63
|
};
|
|
64
64
|
if (error) result.error = String(error);
|
|
65
65
|
if (stopped) result.stopped = true;
|
|
66
|
+
// `--timeout <seconds>` tripped: distinct from a user interrupt so a caller can
|
|
67
|
+
// tell "the model was too slow" from "someone pressed Esc" (both set stopped).
|
|
68
|
+
if (timedOut) result.timedOut = true;
|
|
66
69
|
return result;
|
|
67
70
|
}
|
|
68
71
|
|