Choose which skills the model is offered, with /skills

0607b7dfa2d9 · AtlantisPleb · · parent 14608415c5f5

Choose which skills the model is offered, with /skills

A skill costs context whether or not it is used: its description sits in the
`skill` tool for the whole session, and six of them is most of what that tool
says. A reader who knows a skill is irrelevant to today's work had no way to
take it out.

`/skills` is a screen over the chat rather than a turn in it, for the reason
`/system` is a notice: switching a skill off changes what the next turn carries,
so it is not something to say to the model. Arrow keys move, space switches, and
escape returns. The screen holds the keyboard while it is up, so a stray letter
cannot land in a composer the reader cannot see.

A switched-off skill is left out of the tool entirely -- out of the catalog in
its description and out of the enum of names it accepts -- so the model is not
told the skill exists and cannot ask for it. The tools are re-declared at the
keystroke rather than on the next turn, so `/system` agrees with the screen the
moment it is left.

The choice is recorded per workspace, keyed by path, because the reason to
switch a skill off is usually the repository rather than the machine. It is
written under the config directory and not in the repository, so switching one
off is not a change someone else has to review. Off is what is recorded, not on:
a skill added later is offered until someone rules on it, which is how a session
that has never been touched behaves. A preference file that cannot be read is a
preference nobody set, and a preference that cannot be written still holds for
the session -- neither is a reason to refuse to start.

The plain lane has no screen, so `/skills` reports the same facts without the
switch. Sending it to the model as a question would be worse than either.

307 tests pass. Six drive the screen through real keystrokes: that it lists
each skill with its state, that arrows move, that space switches and
re-declares, that nothing reaches the model on the way in or out, that typing
does not fall through to the composer, and that an empty workspace says so.
Seven more cover the choice itself, including that it stays in the workspace it
was made in and that a skill added afterwards is still offered.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/coder-plain.ts
  • modified packages/openagents-cli/src/coder-skills.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • modified packages/openagents-cli/test/coder-skills.test.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts

Diff

6 files changed, +521 -13

packages/openagents-cli/src/cli.ts modified +20 -9

@@ -31,7 +31,7 @@ import { backendIds } from "./coder-backends.js";

31 31
import { OllamaReplySource, isOllamaModelFlag, parseOllamaModelFlag } from "./coder-ollama.js";
32 32
import { openThread, ThreadUnavailable, type ThreadReplySource } from "./coder-thread.js";
33 33
import { delegateTool, skillTool } from "./coder-tools.js";
34
import { discoverSkills } from "./coder-skills.js";
34
import { loadSkillSelection } from "./coder-skills.js";
35 35
import { describeWorkspace } from "./coder-workspace.js";
36 36
import { ComputerClient } from "./computer-client.js";
37 37
import { ComputerUp } from "./computer-up.js";

@@ -1744,14 +1744,19 @@ const coderCommand = Command.make(

1744 1744
      // Skills do not depend on delegation: a session with no credential still
1745 1745
      // reads this repository's conventions, it just cannot hand work to a
1746 1746
      // child. A session with neither declares no tools at all.
1747
      const skills = discoverSkills();
1748
      const tools = [
1749
        ...(skills.length === 0 ? [] : [skillTool(skills)]),
1750
        ...(setup === undefined ? [] : [delegateTool(setup.delegation)]),
1751
      ];
1752
      if (tools.length > 0) {
1747
      const skills = loadSkillSelection();
1748
      // Re-declared rather than declared once: switching a skill off in
1749
      // `/skills` has to change what the next turn carries, and the tool
1750
      // holding the catalog is the thing that changes.
1751
      const declareTools = () => {
1752
        const active = skills.active();
1753
        const tools = [
1754
          ...(active.length === 0 ? [] : [skillTool(active)]),
1755
          ...(setup === undefined ? [] : [delegateTool(setup.delegation)]),
1756
        ];
1753 1757
        source.useTools?.(tools);
1754
      }
1758
      };
1759
      declareTools();
1755 1760
1756 1761
      // Delegation is off rather than quietly running children on the
1757 1762
      // conversation's model, so the refusal that turned it off is what the

@@ -1790,11 +1795,17 @@ const coderCommand = Command.make(

1790 1795
      const code = yield* Effect.promise(async () => {
1791 1796
        try {
1792 1797
          return interactive
1793
            ? await runCoderUi(session, { stdin: process.stdin, stdout: process.stdout })
1798
            ? await runCoderUi(session, {
1799
                stdin: process.stdin,
1800
                stdout: process.stdout,
1801
                skills,
1802
                onSkillsChanged: declareTools,
1803
              })
1794 1804
            : await runCoderPlain(session, {
1795 1805
                stdin: process.stdin,
1796 1806
                stdout: process.stdout,
1797 1807
                prompt: oneShot,
1808
                skills,
1798 1809
              });
1799 1810
        } finally {
1800 1811
          // An account holds eight open threads at once. A terminal that closed
packages/openagents-cli/src/coder-plain.ts modified +21

@@ -19,12 +19,15 @@

19 19
import { createInterface } from "node:readline";
20 20
21 21
import type { CoderEntry, CoderSession } from "./coder-session.js";
22
import type { SkillSelection } from "./coder-skills.js";
22 23
23 24
export interface CoderPlainOptions {
24 25
  readonly stdin: NodeJS.ReadableStream;
25 26
  readonly stdout: NodeJS.WritableStream;
26 27
  /** When set, answer this one prompt and exit rather than reading a loop. */
27 28
  readonly prompt?: string | undefined;
29
  /** The workspace's skills, so `/skills` can report them. */
30
  readonly skills?: SkillSelection | undefined;
28 31
}
29 32
30 33
export async function runCoderPlain(

@@ -69,6 +72,24 @@ export async function runCoderPlain(

69 72
  flush();
70 73
71 74
  const answer = async (line: string) => {
75
    // `/skills` is a screen in the interface. There is no screen here, so it
76
    // reports instead: the same facts, without the switch. Saying nothing and
77
    // sending it to the model as a question would be worse than either.
78
    if (/^\/skills\s*$/.test(line.trim())) {
79
      const all = options.skills?.all ?? [];
80
      stdout.write(
81
        all.length === 0
82
          ? "\nNo skills were found.\n"
83
          : `\n${all
84
              .map(
85
                (skill) =>
86
                  `${options.skills?.isOn(skill.name) ?? true ? "[on] " : "[off]"} ${skill.name}`,
87
              )
88
              .join("\n")}\nRun the interactive session to switch one.\n`,
89
      );
90
      return;
91
    }
92
72 93
    written = 0;
73 94
    stdout.write(`\ncoder> `);
74 95
    await session.submit(line);
packages/openagents-cli/src/coder-skills.ts modified +93 -2

@@ -18,9 +18,9 @@

18 18
 * server's. A tool description reaches both.
19 19
 */
20 20
21
import { readdirSync, readFileSync, statSync } from "node:fs";
21
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
22 22
import { homedir } from "node:os";
23
import { join } from "node:path";
23
import { dirname, join } from "node:path";
24 24
25 25
/** Where skills live, nearest first. A repository skill wins its name. */
26 26
const SKILL_DIRECTORIES = (cwd: string, home: string): ReadonlyArray<string> => [

@@ -153,3 +153,94 @@ export const renderSkill = (skill: CoderSkill): string => {

153 153
      : skill.body;
154 154
  return `Skill \`${skill.name}\` (${skill.path}):\n\n${body}`;
155 155
};
156
157
/**
158
 * Which skills this workspace offers the model.
159
 *
160
 * A skill costs context whether or not it is used: its description sits in the
161
 * `skill` tool for the whole session. A reader who knows a skill is irrelevant
162
 * to today's work should be able to take it out, and have it stay out.
163
 *
164
 * The choice is stored per workspace, keyed by path, because the reason to
165
 * switch a skill off is usually the repository rather than the machine. It is
166
 * stored under the config directory rather than in the repository so that
167
 * switching one off is not a change someone else has to review.
168
 *
169
 * Off is recorded, not on: a skill added later is on until someone says
170
 * otherwise, which is the behaviour of a session that has never been touched.
171
 */
172
export interface SkillSelection {
173
  /** Every skill found, switched on or off. */
174
  readonly all: ReadonlyArray<CoderSkill>;
175
  /** Whether this skill is offered to the model. */
176
  isOn(name: string): boolean;
177
  /** Switch one skill, persist the choice, and return its new state. */
178
  toggle(name: string): boolean;
179
  /** The skills the model is offered, in catalog order. */
180
  active(): ReadonlyArray<CoderSkill>;
181
}
182
183
const selectionPath = (home: string): string =>
184
  join(home, ".config", "openagents", "coder-skills.json");
185
186
/** Names switched off for this workspace, or none when nothing is recorded. */
187
const readDisabled = (path: string, workspace: string): ReadonlySet<string> => {
188
  try {
189
    const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
190
    if (typeof parsed !== "object" || parsed === null) return new Set();
191
    const entry = (parsed as Record<string, unknown>)[workspace];
192
    if (!Array.isArray(entry)) return new Set();
193
    return new Set(entry.filter((name): name is string => typeof name === "string"));
194
  } catch {
195
    // No file, unreadable, or not the shape this writes. A preference nobody
196
    // can read is a preference nobody set, and the session opens with every
197
    // skill on rather than refusing to start.
198
    return new Set();
199
  }
200
};
201
202
const writeDisabled = (path: string, workspace: string, disabled: ReadonlySet<string>): void => {
203
  let all: Record<string, unknown> = {};
204
  try {
205
    const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
206
    if (typeof parsed === "object" && parsed !== null) all = parsed as Record<string, unknown>;
207
  } catch {
208
    // Start a fresh file rather than lose the choice being made now.
209
  }
210
211
  // An empty list is the default, so it is removed rather than recorded. A file
212
  // of empty arrays is a file that says nothing.
213
  if (disabled.size === 0) delete all[workspace];
214
  else all[workspace] = [...disabled].sort();
215
216
  try {
217
    mkdirSync(dirname(path), { recursive: true });
218
    writeFileSync(path, `${JSON.stringify(all, undefined, 2)}\n`, "utf8");
219
  } catch {
220
    // The choice still holds for this session. A preference that cannot be
221
    // written is not a reason to refuse the preference.
222
  }
223
};
224
225
/** Read the skills for this workspace and the choice made about them. */
226
export function loadSkillSelection(
227
  cwd: string = process.cwd(),
228
  home: string = homedir(),
229
): SkillSelection {
230
  const all = discoverSkills(cwd, home);
231
  const path = selectionPath(home);
232
  const disabled = new Set(readDisabled(path, cwd));
233
234
  return {
235
    all,
236
    isOn: (name) => !disabled.has(name),
237
    toggle: (name) => {
238
      const on = disabled.has(name);
239
      if (on) disabled.delete(name);
240
      else disabled.add(name);
241
      writeDisabled(path, cwd, disabled);
242
      return on;
243
    },
244
    active: () => all.filter((skill) => !disabled.has(skill.name)),
245
  };
246
}
packages/openagents-cli/src/coder-ui.ts modified +151 -1

@@ -32,6 +32,7 @@

32 32
import { fleetPhrase, fleetRows } from "./coder-fleet.js";
33 33
import { renderMarkdown, visibleWidth, wrapStyled } from "./coder-markdown.js";
34 34
import type { CoderEntry, CoderSession, CoderSnapshot, CoderToolCall } from "./coder-session.js";
35
import type { SkillSelection } from "./coder-skills.js";
35 36
import type { CoderTaskStatus } from "./coder-tasks.js";
36 37
37 38
const ALT_SCREEN_ON = "\x1b[?1049h";

@@ -81,6 +82,15 @@ const GUTTER = 9;

81 82
const ESCAPE_WINDOW_MS = 40;
82 83
83 84
export interface CoderUiOptions {
85
  /**
86
   * The workspace's skills and the choice made about them, for `/skills`.
87
   *
88
   * Optional so a caller with no skills, and every test, can leave it out; the
89
   * screen then says there are none rather than being unreachable.
90
   */
91
  readonly skills?: SkillSelection | undefined;
92
  /** Re-declare the tools after a skill is switched. */
93
  readonly onSkillsChanged?: (() => void) | undefined;
84 94
  readonly stdin: NodeJS.ReadStream;
85 95
  readonly stdout: NodeJS.WriteStream;
86 96
}

@@ -216,6 +226,14 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

216 226
  const { stdin, stdout } = options;
217 227
218 228
  let composer = "";
229
  /**
230
   * Which screen has the keyboard. The chat is the interface; `/skills` is a
231
   * screen over it rather than a turn in it, because switching a skill off is
232
   * a change to what the next turn carries, not something to say to the model.
233
   */
234
  let screen: "chat" | "skills" = "chat";
235
  /** The row `/skills` acts on. */
236
  let skillRow = 0;
219 237
  /**
220 238
   * The first transcript line the viewport shows, or undefined while the
221 239
   * viewport follows the newest content.

@@ -402,11 +420,87 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

402 420
      return undefined;
403 421
    };
404 422
423
    /**
424
     * The `/skills` screen: every skill found, and whether the model is told
425
     * about it.
426
     *
427
     * The description is shown because it is the whole of what a switched-on
428
     * skill costs and the whole of what the model chooses on. A reader deciding
429
     * whether to keep one needs to see the sentence the model sees.
430
     */
431
    const skillsLines = (width: number): ReadonlyArray<string> => {
432
      const rows: string[] = [];
433
      const all = options.skills?.all ?? [];
434
435
      rows.push(`${BOLD}Skills${RESET}`, "");
436
      if (all.length === 0) {
437
        rows.push(
438
          ...wrapStyled(
439
            "No skills were found. A skill is a directory holding a SKILL.md, under " +
440
              ".agents/skills in this repository or under your home directory.",
441
            width,
442
            DIM,
443
          ),
444
        );
445
        return rows;
446
      }
447
448
      rows.push(
449
        ...wrapStyled(
450
          "Switched-off skills are left out of the tool the model is given, so they cost " +
451
            "it nothing and it cannot call them. The choice is remembered for this workspace.",
452
          width,
453
          DIM,
454
        ),
455
        "",
456
      );
457
458
      for (const [at, skill] of all.entries()) {
459
        const on = options.skills?.isOn(skill.name) ?? true;
460
        const focused = at === skillRow;
461
        const mark = on ? `${GREEN}[on] ${RESET}` : `${DIM}[off]${RESET}`;
462
        const caret = focused ? `${CYAN}❯${RESET} ` : "  ";
463
        const name = focused ? `${BOLD}${skill.name}${RESET}` : skill.name;
464
        rows.push(`${caret}${mark} ${on ? name : `${DIM}${skill.name}${RESET}`}`);
465
        // The description is indented under its own row, dim, and only for the
466
        // row in hand: eight descriptions at once is the wall of text the
467
        // catalog exists to avoid.
468
        if (focused) {
469
          rows.push(...wrapStyled(skill.description, Math.max(20, width - 8), DIM).map(
470
            (line) => `        ${line}`,
471
          ));
472
        }
473
      }
474
475
      return rows;
476
    };
477
405 478
    const render = () => {
406 479
      if (closed) return;
407 480
      const snapshot = session.snapshot();
408 481
      const width = stdout.columns ?? 80;
409 482
      const height = stdout.rows ?? 24;
483
484
      if (screen === "skills") {
485
        const body = skillsLines(width);
486
        const rows: string[] = [];
487
        for (let row = 0; row < Math.max(1, height - 2); row += 1) rows.push(body[row] ?? "");
488
        rows.push(
489
          `${DIM}${"─".repeat(Math.max(0, width))}${RESET}`,
490
          hints(
491
            [
492
              { text: "↑↓ move" },
493
              { text: "space toggles" },
494
              { text: "esc returns" },
495
            ],
496
            "",
497
            width,
498
          ),
499
        );
500
        // No cursor to place: the screen is a list, not a field.
501
        paint(rows, rows.length, 1);
502
        return;
503
      }
410 504
      const transcriptHeight = Math.max(1, height - STATUS_ROWS - COMPOSER_ROWS - 1);
411 505
412 506
      const fleet = fleetLines(snapshot, width);

@@ -564,6 +658,16 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

564 658
      // A delegate line is not a turn: it returns as soon as the children are
565 659
      // submitted and each one reports later, so nothing here waits on it and
566 660
      // the ticker above keeps the fleet rows moving.
661
      // `/skills` opens a screen rather than sending a turn: it changes what
662
      // the next turn carries, so it is not something to say to the model.
663
      if (/^\/skills\s*$/.test(prompt.trim())) {
664
        screen = "skills";
665
        skillRow = 0;
666
        painted = [];
667
        render();
668
        return;
669
      }
670
567 671
      if (prompt.trimStart().startsWith("/delegate")) {
568 672
        void session.submit(prompt);
569 673
        render();

@@ -614,6 +718,51 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

614 718
      while (index < text.length) {
615 719
        const char = text[index] ?? "";
616 720
721
        // The skills screen takes the keyboard while it is up. Only the keys it
722
        // names do anything: a stray letter must not fall through into the
723
        // composer of a screen the reader cannot see.
724
        if (screen === "skills") {
725
          const count = options.skills?.all.length ?? 0;
726
727
          if (char === "\x1b") {
728
            const sequence = matchEscapeSequence(text, index);
729
            if (sequence === undefined) {
730
              pendingEscape = text.slice(index);
731
              break;
732
            }
733
            index += sequence.length;
734
            if (sequence === "\x1b") {
735
              screen = "chat";
736
              painted = [];
737
            } else if (sequence === "\x1b[A" || sequence === "\x1bOA") {
738
              skillRow = Math.max(0, skillRow - 1);
739
            } else if (sequence === "\x1b[B" || sequence === "\x1bOB") {
740
              skillRow = Math.min(Math.max(0, count - 1), skillRow + 1);
741
            } else {
742
              continue;
743
            }
744
            render();
745
            continue;
746
          }
747
748
          index += 1;
749
          if (char === " ") {
750
            const skill = options.skills?.all[skillRow];
751
            if (skill !== undefined) {
752
              options.skills?.toggle(skill.name);
753
              // Re-declared now rather than on the next turn, so `/system`
754
              // agrees with this screen the moment it is left.
755
              options.onSkillsChanged?.();
756
            }
757
            render();
758
          } else if (char === "\x03" || char === "\x04") {
759
            screen = "chat";
760
            painted = [];
761
            render();
762
          }
763
          continue;
764
        }
765
617 766
        if (char === "\x1b") {
618 767
          const sequence = matchEscapeSequence(text, index);
619 768
          if (sequence === undefined) {

@@ -757,7 +906,8 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

757 906
758 907
    session.notice(
759 908
      "openagents coder — development build. Type a message and press enter. " +
760
        "Ctrl+D quits, Esc interrupts a reply. `/system` shows what the model is told.",
909
        "Ctrl+D quits, Esc interrupts a reply. `/system` shows what the model is told, " +
910
        "`/skills` chooses which skills it is offered.",
761 911
    );
762 912
    render();
763 913
  });
packages/openagents-cli/test/coder-skills.test.ts modified +83 -1

@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";

3 3
import { join } from "node:path";
4 4
import { describe, expect, it } from "vitest";
5 5
6
import { discoverSkills } from "../src/coder-skills.js";
6
import { discoverSkills, loadSkillSelection } from "../src/coder-skills.js";
7 7
import { skillTool } from "../src/coder-tools.js";
8 8
9 9
/** A repository with the given skills under `.agents/skills`. */

@@ -145,3 +145,85 @@ describe("the skill tool", () => {

145 145
    expect(output).toContain("`house-style`");
146 146
  });
147 147
});
148
149
describe("choosing which skills the model is offered", () => {
150
  /** A home the test owns, so the choice is written where it can be read back. */
151
  const home = () => mkdtempSync(join(tmpdir(), "coder-skills-home-"));
152
153
  const two = {
154
    alpha: "---\nname: alpha\ndescription: First.\n---\n\nBody.",
155
    beta: "---\nname: beta\ndescription: Second.\n---\n\nBody.",
156
  };
157
158
  it("offers every skill until one is switched off", () => {
159
    const selection = loadSkillSelection(workspace(two), home());
160
161
    expect(selection.active().map((skill) => skill.name)).toEqual(["alpha", "beta"]);
162
    expect(selection.isOn("alpha")).toBe(true);
163
  });
164
165
  it("drops a switched-off skill from what the model is offered", () => {
166
    const selection = loadSkillSelection(workspace(two), home());
167
168
    selection.toggle("alpha");
169
170
    expect(selection.isOn("alpha")).toBe(false);
171
    expect(selection.active().map((skill) => skill.name)).toEqual(["beta"]);
172
    // Still found, so the screen can offer to switch it back on.
173
    expect(selection.all).toHaveLength(2);
174
  });
175
176
  it("remembers the choice for the next session", () => {
177
    const root = workspace(two);
178
    const where = home();
179
180
    loadSkillSelection(root, where).toggle("beta");
181
182
    expect(loadSkillSelection(root, where).active().map((skill) => skill.name)).toEqual(["alpha"]);
183
  });
184
185
  it("switches one back on", () => {
186
    const root = workspace(two);
187
    const where = home();
188
189
    loadSkillSelection(root, where).toggle("beta");
190
    loadSkillSelection(root, where).toggle("beta");
191
192
    expect(loadSkillSelection(root, where).active().map((skill) => skill.name)).toEqual([
193
      "alpha",
194
      "beta",
195
    ]);
196
  });
197
198
  it("keeps the choice to the workspace it was made in", () => {
199
    const where = home();
200
    const one = workspace(two);
201
    const other = workspace(two);
202
203
    loadSkillSelection(one, where).toggle("alpha");
204
205
    // A skill switched off for one repository is not switched off everywhere.
206
    expect(loadSkillSelection(other, where).active().map((skill) => skill.name)).toEqual([
207
      "alpha",
208
      "beta",
209
    ]);
210
  });
211
212
  it("offers a skill added after the choice was made", () => {
213
    const root = workspace(two);
214
    const where = home();
215
    loadSkillSelection(root, where).toggle("alpha");
216
217
    mkdirSync(join(root, ".agents", "skills", "gamma"), { recursive: true });
218
    writeFileSync(
219
      join(root, ".agents", "skills", "gamma", "SKILL.md"),
220
      "---\nname: gamma\ndescription: Third.\n---\n\nBody.",
221
    );
222
223
    // Off is what is recorded, so something nobody has ruled on is on.
224
    expect(loadSkillSelection(root, where).active().map((skill) => skill.name)).toEqual([
225
      "beta",
226
      "gamma",
227
    ]);
228
  });
229
});
packages/openagents-cli/test/coder-ui.test.ts modified +153

@@ -389,3 +389,156 @@ describe("runCoderUi", () => {

389 389
    expect(composer).toContain("…");
390 390
  });
391 391
});
392
393
describe("the /skills screen", () => {
394
  const skill = (name: string, description: string) => ({
395
    name,
396
    description,
397
    body: "Body.",
398
    path: `/tmp/${name}/SKILL.md`,
399
  });
400
401
  /** A selection over two skills, recording what was switched. */
402
  const selection = () => {
403
    const off = new Set<string>();
404
    const all = [skill("alpha", "The first skill."), skill("beta", "The second skill.")];
405
    return {
406
      all,
407
      isOn: (name: string) => !off.has(name),
408
      toggle: (name: string) => {
409
        const on = off.has(name);
410
        if (on) off.delete(name);
411
        else off.add(name);
412
        return on;
413
      },
414
      active: () => all.filter((candidate) => !off.has(candidate.name)),
415
    };
416
  };
417
418
  const open = async (skills: ReturnType<typeof selection>) => {
419
    const stdin = new FakeIn();
420
    const stdout = new FakeOut();
421
    const session = new CoderSession(source([]), "repo", "main");
422
    let declared = 0;
423
    const running = runCoderUi(session, {
424
      stdin: stdin as unknown as NodeJS.ReadStream,
425
      stdout: stdout as unknown as NodeJS.WriteStream,
426
      skills,
427
      onSkillsChanged: () => {
428
        declared += 1;
429
      },
430
    });
431
432
    // Typed the way a reader types it, then entered.
433
    stdin.emit("data", "/skills");
434
    stdin.emit("data", "\r");
435
436
    return {
437
      stdin,
438
      stdout,
439
      session,
440
      running,
441
      declarations: () => declared,
442
      rows: () => screen(stdout.written),
443
      close: async () => {
444
        stdin.emit("data", "\x1b");
445
        stdin.emit("data", "\x04");
446
        await running;
447
      },
448
    };
449
  };
450
451
  it("lists every skill with its state, and describes the row in hand", async () => {
452
    const screenUnderTest = await open(selection());
453
    const rows = screenUnderTest.rows().join("\n");
454
455
    expect(rows).toContain("Skills");
456
    expect(rows).toContain("[on]  alpha");
457
    expect(rows).toContain("[on]  beta");
458
    // The description of the focused row only: eight at once is the wall of
459
    // text the catalog exists to avoid.
460
    expect(rows).toContain("The first skill.");
461
    expect(rows).not.toContain("The second skill.");
462
463
    await screenUnderTest.close();
464
  });
465
466
  it("moves the focus with the arrow keys", async () => {
467
    const screenUnderTest = await open(selection());
468
469
    screenUnderTest.stdin.emit("data", "\x1b[B");
470
    const rows = screenUnderTest.rows().join("\n");
471
472
    expect(rows).toContain("The second skill.");
473
474
    await screenUnderTest.close();
475
  });
476
477
  it("switches the focused skill with space and re-declares the tools", async () => {
478
    const skills = selection();
479
    const screenUnderTest = await open(skills);
480
481
    screenUnderTest.stdin.emit("data", " ");
482
483
    expect(skills.isOn("alpha")).toBe(false);
484
    expect(skills.active().map((candidate) => candidate.name)).toEqual(["beta"]);
485
    // Re-declared at the keystroke, so what the model is told matches the
486
    // screen the moment it is left.
487
    expect(screenUnderTest.declarations()).toBe(1);
488
    expect(screenUnderTest.rows().join("\n")).toContain("[off] alpha");
489
490
    await screenUnderTest.close();
491
  });
492
493
  it("sends nothing to the model, on the way in or out", async () => {
494
    const skills = selection();
495
    const screenUnderTest = await open(skills);
496
497
    screenUnderTest.stdin.emit("data", " ");
498
    screenUnderTest.stdin.emit("data", "\x1b");
499
500
    // `/skills` is a screen, not a turn: no prompt was sent and none was
501
    // recorded as one.
502
    expect(screenUnderTest.session.snapshot().turns).toBe(0);
503
    expect(
504
      screenUnderTest.session.snapshot().entries.filter((entry) => entry.role === "you"),
505
    ).toEqual([]);
506
507
    screenUnderTest.stdin.emit("data", "\x04");
508
    await screenUnderTest.running;
509
  });
510
511
  it("holds the keyboard, so typing does not reach the composer behind it", async () => {
512
    const screenUnderTest = await open(selection());
513
514
    screenUnderTest.stdin.emit("data", "hello");
515
    screenUnderTest.stdin.emit("data", "\x1b");
516
    const rows = screenUnderTest.rows().join("\n");
517
518
    // The letters went nowhere: a screen the reader cannot see must not be
519
    // collecting what they type.
520
    expect(rows).not.toContain("hello");
521
522
    screenUnderTest.stdin.emit("data", "\x04");
523
    await screenUnderTest.running;
524
  });
525
526
  it("says so when the workspace has no skills", async () => {
527
    const stdin = new FakeIn();
528
    const stdout = new FakeOut();
529
    const session = new CoderSession(source([]), "repo", "main");
530
    const running = runCoderUi(session, {
531
      stdin: stdin as unknown as NodeJS.ReadStream,
532
      stdout: stdout as unknown as NodeJS.WriteStream,
533
    });
534
535
    stdin.emit("data", "/skills");
536
    stdin.emit("data", "\r");
537
538
    expect(screen(stdout.written).join("\n")).toContain("No skills were found");
539
540
    stdin.emit("data", "\x1b");
541
    stdin.emit("data", "\x04");
542
    await running;
543
  });
544
});

This page updates live while a promote is in flight · changelog