Skip to content

Commit 6820015

Browse files
committed
fix(web/shell): #107 review — a refused commit's "NONE … applied" line is always shown in a terminal
The bug: both tabs showed only the first 8 notes of a run. A refusal is eight notes on its own (six reasons, "and N more refused", then "NONE of this command's file changes were applied"), from the terminal's commit (web/shell/src/commit.js) and from levelcode-ai's review (shellCommit.js) alike, and the runtime puts the notes the run already had in front of it: a moved folder (said on every run), an emptied /tmp or /home/user, the engine's own notes, variables not kept. With one of those the NONE line was the ninth note and was cut, so the visitor saw six refusals and never read that nothing was applied. The fix: notesShown() in output-sink.js, used by pty.js and agent-tab.js. Still at most 8 notes in their order; when there are more, a note that says the changes were NOT applied ("NONE of …", "… were/was NOT applied") takes the place of the last of the others. The agent's tab drops non-string notes before the cap instead of letting them use a place. README section 7 and terminal/NOTES.md say so. Tests: shell-terminal-pty ("a refused commit's NONE … line is shown behind a note the run already had") feeds the terminal commit's real refusal notes behind an emptied-/tmp note; shell-terminal-agent-tab ("a refused review's NONE … line …") feeds levelcode-ai's real refusal notes behind one and three notes; shell-terminal-sink pins notesShown() itself (the runtime's own NOT applied endings, a plain cut without one, a file name with "none" in it). All three fail on the unfixed code (the NONE line missing; notesShown not a function) and pass now.
1 parent 187fd21 commit 6820015

8 files changed

Lines changed: 111 additions & 10 deletions

File tree

‎web/shell/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,7 @@ The agent's commands are mirrored into one read-only tab "LevelCode Agent" witho
322322
| completion | – | 200 names, 2 s |
323323
| what a terminal remembers for links | – | 400 lines of 4,000 characters |
324324
| an `explanation` or a note shown in the agent's tab | the first 4,800 characters scanned, 300 shown, at most 8 notes | – |
325+
| notes shown after a run (either tab) | at most 8; one that says the changes were NOT applied is always among them (`notesShown`) | same |
325326

326327
The engine's own table (`engine/limits.js`) has 45 keys; `LIMITS.engine` above is the ones the shell sets, the engine's
327328
own values the rest.

‎web/shell/src/terminal/NOTES.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,10 @@ One reusable read-only tab, appended to with a header (explanation dim, `$ comma
182182
a result line (`✓ exit 0 · 1.4s`, `✗ exit N`, `■ stopped`, `■ timed out`, `■ stopped: a limit of the shell was reached`).
183183
The command text, explanation, cwd and notes go through `forTerminal`, the output through the filter. Caps: 1 MiB for
184184
the output; the first line of a command 300 characters; the explanation and each note 300 characters (cut on what is
185-
shown, with `…`), at most 8 notes. The explanation is written first and **counts in the run's 1 MiB**; the notes come
186-
after the output, so counting them would change nothing, and they are bounded by 8 x 300 instead. Only the first 4,800
185+
shown, with `…`), at most 8 notes - a note that says the changes were NOT applied is always one of them, in the place
186+
of the last of the others (`notesShown` in `output-sink.js`; the visitor's terminal shows its notes the same way). The
187+
explanation is written first and **counts in the run's 1 MiB**; the notes come after the output, so counting them would
188+
change nothing, and they are bounded by 8 x 300 instead. Only the first 4,800
187189
characters of an explanation or a note are looked at (blank lines past that are not read through), and the join of its
188190
lines is linear.
189191

‎web/shell/src/terminal/agent-tab.js‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
'use strict';
1414

1515
const { createInputParser } = require('./input-parser');
16-
const { createWriter, createRunOutput, OUTPUT_LIMIT } = require('./output-sink');
16+
const { createWriter, createRunOutput, notesShown, OUTPUT_LIMIT } = require('./output-sink');
1717
const { cwdLabel } = require('./prompt');
1818
const { createLineLog } = require('./line-log');
1919

@@ -30,7 +30,7 @@ const MAX_TEXT_SHOWN = 300;
3030
* The cut is made before anything is scanned, so a megabyte of blanks costs a slice.
3131
*/
3232
const MAX_TEXT_SCAN = 16 * MAX_TEXT_SHOWN;
33-
/** At most this many notes of a run are shown. */
33+
/** At most this many notes of a run are shown (one that says its changes were NOT applied always among them: notesShown). */
3434
const MAX_NOTES = 8;
3535

3636
const ESC = '\x1b';
@@ -250,8 +250,8 @@ function createAgentTab(deps) {
250250
const run = current;
251251
if (!run) { return; }
252252
let s = run.output.end();
253-
const notes = Array.isArray(e.notes) ? e.notes.slice(0, MAX_NOTES) : [];
254-
for (const n of notes) { if (typeof n === 'string') { s += DIM + clip(n) + RESET + '\r\n'; } }
253+
const notes = Array.isArray(e.notes) ? notesShown(e.notes.filter((/** @type {unknown} */ n) => typeof n === 'string'), MAX_NOTES) : [];
254+
for (const n of notes) { s += DIM + clip(n) + RESET + '\r\n'; }
255255
const took = duration(now() - run.startedAt);
256256
const code = Number.isFinite(e.exitCode) ? Math.trunc(e.exitCode) : 1;
257257
switch (e.how) {

‎web/shell/src/terminal/output-sink.js‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,27 @@ function createRunOutput(o) {
138138
};
139139
}
140140

141-
module.exports = { createWriter, createRunOutput, CHUNK, OUTPUT_LIMIT, RESET_TAIL };
141+
/**
142+
* A note that says a command's file changes were NOT applied - "NONE of this command's file changes were applied",
143+
* "Any file changes it had made were NOT applied", "Its 3 file changes were NOT applied". The shell and levelcode-ai's
144+
* review write it in capitals, and a cap on the notes must never be what drops it.
145+
*/
146+
const NOT_APPLIED = /\bNONE of\b|\b(?:was|were) NOT applied\b/;
147+
148+
/**
149+
* The notes of a run that a terminal shows after it: at most `max` of them, in their order. When there are more, a
150+
* note that says the changes were NOT applied is still among them, in the place of the last of the others: a refusal
151+
* ends with it (six reasons, the count of the rest, then that line), and the notes the run already had - a moved
152+
* folder, an emptied /tmp, the engine's own - come in front of it, so it was the one a plain cut dropped.
153+
* @param {string[]} notes @param {number} max @returns {string[]}
154+
*/
155+
function notesShown(notes, max) {
156+
if (notes.length <= max) { return notes.slice(); }
157+
/** @type {number[]} */ const said = [];
158+
for (let i = 0; i < notes.length; i++) { if (NOT_APPLIED.test(notes[i])) { said.push(i); } }
159+
const keep = new Set(said.slice(-max));
160+
for (let i = 0; i < notes.length && keep.size < max; i++) { keep.add(i); }
161+
return notes.filter((_, i) => keep.has(i));
162+
}
163+
164+
module.exports = { createWriter, createRunOutput, notesShown, CHUNK, OUTPUT_LIMIT, RESET_TAIL };

‎web/shell/src/terminal/pty.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const { LineEditor } = require('./line-editor');
1616
const { createInputParser, DEFAULT_MAX_PASTE } = require('./input-parser');
1717
const { isComplete } = require('./completeness');
1818
const { createCompleter } = require('./completion');
19-
const { createWriter, createRunOutput, OUTPUT_LIMIT } = require('./output-sink');
19+
const { createWriter, createRunOutput, notesShown, OUTPUT_LIMIT } = require('./output-sink');
2020
const { strWidth, stripEscapes } = require('./width');
2121
const { createLineLog } = require('./line-log');
2222
const { promptFor } = require('./prompt');
@@ -392,7 +392,7 @@ class ShellPty {
392392
*/
393393
_epilogue(code, tail, notes, how) {
394394
let s = tail;
395-
const lines = notes.slice(0, 8).map((n) => this.sanitize.forTerminal(n, { crlf: false }).replace(/\n/g, ' '));
395+
const lines = notesShown(notes, 8).map((n) => this.sanitize.forTerminal(n, { crlf: false }).replace(/\n/g, ' '));
396396
const own = how && how !== 'exit' && how !== 'aborted' && lines.length === 0 ? outcomeLine(how, code) : '';
397397
for (const l of lines) { s += DIM + l + RESET + '\r\n'; }
398398
if (own) { s += YELLOW + '[' + own + ']' + RESET + '\r\n'; }

‎web/test/unit/shell-terminal-agent-tab.test.js‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,33 @@ test('at most 8 notes of a run are shown', () => {
322322
assert.ok(!s.includes('note 9'));
323323
});
324324

325+
test('a refused review\'s "NONE of this command\'s file changes were applied" is shown behind the notes the run already had; still at most 8', async () => {
326+
// What levelcode-ai's review says when it refuses an agent's set (six reasons, the count of the rest, then the NONE
327+
// line), behind what the runtime says on every run of a folder it had to move. The NONE line was the ninth note.
328+
const { commitShellChanges } = require(path.join(__dirname, '..', '..', '..', 'extensions', 'levelcode-ai', 'shellCommit.js'));
329+
const outside = Array.from({ length: 9 }, (_, i) => ({ kind: 'write', path: '/elsewhere/f' + i, existed: false, before: null, after: new Uint8Array([120]) }));
330+
const refused = await commitShellChanges(outside, {}, { folders: () => [{ name: 'scratch', root: '/scratch' }], readNow: async () => null });
331+
assert.strictEqual(refused.refused, true);
332+
assert.strictEqual(refused.notes.length, 8, 'six reasons, "…and 3 more refused", and the NONE line');
333+
const none = refused.notes[7];
334+
assert.match(none, /NONE of this command's file changes were applied/);
335+
const lead = '[LevelCode Shell: The folder "home" is mounted at /mnt/home, not at /home: /home is the shell\'s own.]';
336+
for (const leads of [[lead], [lead, '[LevelCode Shell: an engine note]', '[LevelCode Shell: another]']]) {
337+
const t = setup({ mode: 'never' });
338+
t.emit(run(1, 'mv src/* lib/'));
339+
const a = t.attach();
340+
const notes = leads.concat(refused.notes);
341+
t.emit({ type: 'end', id: 1, exitCode: 1, how: 'exit', notes });
342+
t.timers.drain();
343+
const s = a.screen().join('\n');
344+
assert.ok(s.includes('NONE of this command\'s file changes were applied'), leads.length + ' note(s) in front: the NONE line is shown:\n' + s);
345+
const shown = notes.filter((n) => s.includes(n.slice(0, 60)));
346+
assert.strictEqual(shown.length, 8, 'still at most 8: ' + JSON.stringify(shown));
347+
assert.strictEqual(shown[shown.length - 1], none, 'the NONE line takes the last place, after the reasons');
348+
assert.deepStrictEqual(shown.slice(0, 7), notes.slice(0, 7), 'the others in their order');
349+
}
350+
});
351+
325352
test('an explanation and every note are cut at 300 characters, whatever they are made of; the explanation counts in the run\'s budget', () => {
326353
const t = setup({ mode: 'never', outputLimit: 2000 });
327354
const huge = 'e'.repeat(5000000);

‎web/test/unit/shell-terminal-pty.test.js‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,31 @@ test('at most 8 of the runtime\'s notes are shown after a command', async () =>
670670
assert.ok(!s.includes('[note 9]'));
671671
});
672672

673+
test('a refused commit\'s "NONE of this command\'s file changes were applied" is shown behind a note the run already had; still at most 8', async () => {
674+
// What the terminal's own commit says when it refuses a set (six reasons, the count of the rest, then the line that
675+
// says nothing was applied: eight notes), behind a note the runtime puts in front of every ending (a /tmp that was
676+
// emptied, a moved folder, the engine's own). The ninth note used to be cut, and with it the only line that says
677+
// the files were left as they were.
678+
const { commitChanges } = require(path.join(TERMINAL, '..', 'commit.js'));
679+
const outside = Array.from({ length: 9 }, (_, i) => ({ kind: /** @type {const} */ ('write'), path: '/elsewhere/f' + i, existed: false, before: null, after: new Uint8Array([120]) }));
680+
const refused = await commitChanges(outside, /** @type {any} */ ({ resolve: () => null }), { writeFile: 1000, writeRun: 5000, entries: 100 });
681+
assert.strictEqual(refused.notes.length, 8, 'six reasons, "and 3 more refused", and the NONE line');
682+
const none = refused.notes[7];
683+
assert.match(none, /NONE of this command's file changes were applied/);
684+
const lead = '[LevelCode Shell: /tmp was emptied when the shell restarted (it held 2 files)]';
685+
const h = makePty({ cols: 200 });
686+
h.type('x', '\r');
687+
h.session.finish({ exitCode: 1, notes: [lead].concat(refused.notes) });
688+
await settle(); h.timers.drain();
689+
const s = h.screen().join('\n');
690+
assert.ok(s.includes(none), 'the NONE line is shown:\n' + s);
691+
assert.ok(s.includes(lead), 'the note in front is shown');
692+
const shown = [lead].concat(refused.notes).filter((n) => s.includes(n));
693+
assert.strictEqual(shown.length, 8, 'still at most 8: ' + JSON.stringify(shown));
694+
assert.deepStrictEqual(shown, [lead].concat(refused.notes.slice(0, 6), [none]), 'in their order; the NONE line takes the last place');
695+
assert.ok(s.indexOf(none) > s.indexOf(refused.notes[5]), 'and stays where it was, after the reasons');
696+
});
697+
673698
test('dimensions below 2 are never taken (a hidden panel reports 0): the previous ones stay and are what a command is told', async () => {
674699
const h = makePty({ cols: 60, rows: 12 });
675700
for (const bad of [{ columns: 1, rows: 1 }, { columns: 0, rows: 0 }, { columns: NaN, rows: undefined }, { columns: -5, rows: 9 }]) {

‎web/test/unit/shell-terminal-sink.test.js‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const path = require('path');
1515

1616
const dir = path.join(__dirname, '..', '..', 'shell', 'src', 'terminal');
1717
const si = require(path.join(dir, 'shell-integration.js'));
18-
const { createWriter, createRunOutput, CHUNK, OUTPUT_LIMIT, RESET_TAIL } = require(path.join(dir, 'output-sink.js'));
18+
const { createWriter, createRunOutput, notesShown, CHUNK, OUTPUT_LIMIT, RESET_TAIL } = require(path.join(dir, 'output-sink.js'));
1919

2020
const queue = [];
2121
const test = (name, fn) => queue.push([name, fn]);
@@ -174,6 +174,29 @@ test('charge(): text shown outside the filters counts against the run\'s cap', (
174174
assert.ok(/\[output limit reached: 20 more characters were not shown\]/.test(r.end()), 'the output had 40 characters left, not 100');
175175
});
176176

177+
test('notesShown(): at most max notes in their order, and the one that says the changes were NOT applied is never the one cut', () => {
178+
const lead = Array.from({ length: 9 }, (_, i) => '[LevelCode Shell: The folder "f' + i + '" is mounted at /mnt/f' + i + ', not at /home/f' + i + ']');
179+
const few = lead.slice(0, 3);
180+
const copy = notesShown(few, 8);
181+
assert.deepStrictEqual(copy, few);
182+
assert.notStrictEqual(copy, few, 'a copy');
183+
// the runtime's own endings, behind more notes than the cap: a Stop, and a limit that discarded the changes
184+
for (const end of [
185+
'[LevelCode Shell: stopped. Any file changes the command had made were NOT applied.]',
186+
'[LevelCode Shell: a limit of this shell stopped the command. Its 3 file changes were NOT applied.]',
187+
'[LevelCode Shell: a limit of this shell stopped the command. Its 1 file change was NOT applied.]',
188+
'[LevelCode Shell: NONE of the remaining 2 changes were applied (3 of 5 had already been).]',
189+
]) {
190+
assert.deepStrictEqual(notesShown(lead.concat(end), 8), lead.slice(0, 7).concat(end), end);
191+
}
192+
// without such a note it is the plain cut it was; "none" in a file name is not that note
193+
const plain = lead.concat('[LevelCode Shell: refused: /w/none of these.txt is outside the workspace folders]');
194+
assert.deepStrictEqual(notesShown(plain, 8), plain.slice(0, 8));
195+
// more of them than the cap: the last ones (the run's own ending is last)
196+
const many = Array.from({ length: 10 }, (_, i) => 'NONE of ' + i);
197+
assert.deepStrictEqual(notesShown(many, 8), many.slice(2));
198+
});
199+
177200
let finished = false;
178201
// a test that awaits something that never settles leaves nothing to keep the process alive: it would exit 0, silently
179202
process.on('exit', (code) => { if (code === 0 && !finished) { console.error('the tests did not finish: one of them waits for something that never happens'); process.exitCode = 1; } });

0 commit comments

Comments
 (0)