A pane listing the terminal-program profiles the agent has, with a profile shown as formatted text when picked.

A small collection of reusable SKILL.md instructions for coding agents. The skills are plain Markdown, version-controlled, and portable across compatible agent tools.
This README is the reference: what the skills are, which platforms they support, and how to install them. The [wiki][wiki] answers the other question — why the project is built this way: why a Markdown repository contains Rust crates, why the dev shell compiles bash 3.2 from source, which design choices were made and what they cost, how to verify any of it yourself, and what does not work yet.
[wiki]: https://github.com/tschallacka/ai-skills/wiki
Pick whichever fits how you work. Any of these opens the same interactive installer, where you choose the skills and the agent destination(s).
npx — installs and runs in one step, nothing left on PATH afterward:
npx --yes --package @tschallacka/ai-skills ai-skills-install
Linux / macOS — the one-command installer, no npm required:
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh | sh
Windows — the identical command, run inside Git Bash or WSL2 (both provide the POSIX sh it needs; there is no separate PowerShell/cmd installer):
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh | sh
See "One-command installer" below for the full list of install destinations, and "npm installation" for a persistent ai-skills-install command on PATH instead of npx.
| Skill | Purpose | Documentation |
|---|---|---|
| Planning | Durable, resumable plans: goals, ordered steps, verification, progress trackers, handoff notes — plus a single-file HTML overview of any plan, and a live served version that updates in place. Steps complete with git-diff atomicity evidence, not decorative checkboxes. | docs |
| Bug report | A defect register in one JSON file: every entry carries its reproduction, observed vs expected, the mechanism, and the verification that fails without the fix. bugs add/bugs update write through shared validation; a closure without proof is refused. | docs |
| Todo | A work queue that outlives the conversation, in one JSON file: nested tasks, every closed item carries its evidence. todo add/todo update keep the register sound; read recipes print user-ready output. | docs |
| Brainstorm | Shapes an under-specified idea into a recorded, agreed picture (brainstorm.md) before planning, with an adversarial completion pass and a plan-vs-implement gate. | docs |
| Post-implementation review | After-the-fact review of built code with concrete proposed fixes, in three passes: implementer self-analysis, an independent solutions agent, and a critical-feedback agent that ranks every fix. | docs |
| Project-specific deviations | Records confirmed project behavior and environment quirks in per-project notes that future agents load instead of re-debugging. | docs |
| Resource-limited testing | Runs heavyweight commands (suites, builds, analyzers, browsers) under platform-appropriate CPU/memory caps, with honest degradation when a platform has no cap mechanism. | docs |
| Chat | RFC-1459 IRC-over-TLS message bus for agents: a rust server a standard TLS IRC client can join, a rust client with UDP discovery and TOFU cert pinning, channels, and additive history/delta reads. | docs |
| Interactive shell | Operates a full-screen terminal program an agent has never seen - nano, mc, lynx, a pager, a menu: a rust PTY wrapper publishing each screen change as one JSONL event, compact row views and deltas to keep context small, element discovery, and a unix-socket client for keys, combos, pastes, mouse and resize. POSIX only. | docs |
| Git worktrees | Parallel agents in one repository: isolated worktree verification, per-agent trees, and merging back in a conflict-aware order without trampling the main checkout. | docs |
| Git merge resolving | Conflicts resolved by what each side changed rather than by ours/theirs: reading intent from history, unions that look like choices, regenerated output, and attributing post-merge failures to the side that caused them. | docs |
| Merge request etiquette | Descriptions a reviewer can act on, in the author's voice: a one-paragraph TLDR, the defect/cause/change body, derived from the branch's commits, and the one case where a collapsible section earns its place. | docs |
| Text etiquette | Shorthand and a clipped register for an agent's prose - chat, dev talk, and its own thinking: facts first, a shared shorthand with an ask-don't-guess rule, praise capped at gj, and the people-please prose banned. Plain english on request. | docs |
| Question etiquette | Numbered questions, lettered options, never a bullet: a reply like Q7b is unambiguous, a partial answer names exactly which numbers are still open, and a lettered list always ends with "none of these, I'll say it myself." | docs |
| AI text editor | Server-owned agent editor tabs with explicit search, revision-aware edits, undo/redo, raw-byte and hex modes, SQLite metadata, and Unix/TCP transport. | docs |
| www | A brake the human can pull, and one the agent pulls on itself when it is thrashing: stop, answer what do we have / what are the values / what are we trying to achieve, in order, then continue with one reasoned step or a numbered question. | docs |
| CI failures | What actually failed in a CI run or pipeline, from a run/pipeline id, a PR/MR number, or a branch: GitHub and GitLab detected from the git remote, named rather than chosen silently, with just the failing lines extracted per job. | docs |
| Decisions | A register of non-blocking questions raised mid-work: Q# ids, lettered options, priority and the branch they came from as context, so a question can be stubbed and left open without blocking a turn. | docs |
| rjq | Parses, filters and searches JSON with the shipped rjq binary, a jq-compatible tool, on machines without jq. | docs |
| Tailpipe | A less/tail for agents: pipe a command's output into a named, server-held stream with chat-style message ids; a reader lists/reads/searches/tails it from anywhere, an MCP adapter offers the same as typed tools, and a board mod shows it to a human. Idle streams are gzip-snapshotted after 15 minutes. | docs |
Use a skill only when its frontmatter trigger matches the task or when the user explicitly requests it. Each skill documents when not to activate.
"Portable" elsewhere in this repository means portable across agent tools (Claude Code, Codex, OpenCode, OpenClaw, Cline). Operating-system support is separate and stated here:
Those tools do not identify the calling agent the same way, which matters to every per-agent feature here. src/agent-session-key/HARNESS-IDENTITY.md records what each one provides, how it was measured, and the procedure for contributing a harness that is not yet listed.
| Supported | |
|---|---|
| Linux | any distribution, bash 4 or 5, GNU userland |
| macOS | 11+ with the stock /bin/bash 3.2, BSD userland; Homebrew bash not required |
| Windows | Git for Windows' bash with its bundled coreutils, checked by CI legs, or WSL2, which is a Linux install. The evidence and the conventions are in .agents/MAINTAINER.md 1.16, which is in a full git checkout and not part of the installed package |
The one-command install needs a POSIX sh (bootstrap.sh has no bash-only constructs), plus curl, tar, awk, and standard coreutils; the installed skills' own helper scripts need bash, POSIX coreutils, awk, sed, grep, and git. The planning skill additionally needs rjq, and on macOS resource-limited-testing needs memlimit; the installer checks for both up front and prints a per-platform install hint rather than failing partway through. Those two are the only extra runtime dependencies any skill has — in particular python3 is not required by anything that gets installed, only by this repository's own benchmark harness. CODE-STYLE.md is the contract these scripts are held to; what CI proves on each platform is mapped in .agents/MAINTAINER.md section 3 (a full git checkout has both; the installed package has neither).
One skill is genuinely OS-scoped: resource-limited-testing enforces a hard RAM cap only on Linux, via a transient systemd --user cgroup v2 scope. On macOS it uses memlimit (MIT, by Jelle Besseling), which refuses allocations past the cap instead of killing the process — a best-effort cap on real resident memory over the process tree, not a cgroup-equivalent guarantee; SKILL.md lists what it does and does not promise. On Apple Silicon macOS the installer declares memlimit a soft requirement: without it the skill still installs and the run warns that the RAM cap is not enforced. The wrapper then degrades to nice plus cpulimit (CPU throttling only); on a Linux session without a user systemd instance it falls back to ulimit -v. memlimit is not asked for on Intel Macs at all — it does not support them. Every other skill behaves identically on both.
Run this command and choose the skills and agent destination interactively:
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh | sh
The installer can install all the skills or one skill, and supports these global skill roots:
| Destination | Agent or standard |
|---|---|
~/.agents/skills | Universal Agent Skills root; recommended shared destination |
~/.codex/skills | Codex CLI |
~/.claude/skills | Claude Code |
~/.config/opencode/skills | OpenCode |
~/.openclaw/skills | OpenClaw managed skills |
~/.cline/skills | Cline |
The universal root is also discovered by OpenCode and OpenClaw. Installing the same skill into multiple roots can create duplicate definitions or precedence conflicts, so choose only the roots you need.
The interactive installer checks which supported agents are present and omits roots for agents it cannot detect. Custom roots are saved in ~/.config/tsch-ai-skills/custom-locations and are offered again when they still exist.
Install the package globally to expose the installer command. The npm package keeps the skills in this repository and links ai-skills-install directly to installer/bootstrap.sh, which fetches the matching compiled installer release for your platform on first run:
npm install -g @tschallacka/ai-skills
ai-skills-install
For a one-off run without a global install:
npx --yes --package @tschallacka/ai-skills ai-skills-install
The npm package does not install skills automatically as an npm lifecycle side-effect; run the installer command when you are ready to choose a target.
Run the installer again against the same root. It compares every managed file with the repository copy and reports one of three outcomes per destination: Up to date (nothing differed), Installed (files were written), or Skipped (you declined, or the destination needs manual review).
Each installed skill contains a .version marker identifying its tag, branch, and commit. If an installed file differs from the repository version, the installer asks before replacing it. For a managed version transition that the user approves — the .version marker differs, so the change came from a new release rather than from you — old files are replaced without backups; the previous version can be restored by running the installer against its tag with AI_SKILLS_REF. Unmanaged changes still receive <file>.bak backups (.bak.1, .bak.2, … if one already exists). A symlinked skill is skipped for manual review rather than following the link and modifying an unexpected location.
Interactively, choose that one skill at the menu. Headless, name it:
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh \
| sh -s -- install --skill planning --target "$HOME/.codex/skills"
--skill may be given more than once, and each value may itself be a comma-separated list, so --skill planning --skill brainstorm and --skill planning,brainstorm install the same two. Repeats are collapsed, menu numbers work (--skill 1,4), and all selects everything wherever it appears.
Use --all, --skill, --target and --yes when the choices are already known. --target takes a single root, so installing into two roots is two runs.
# Install all skills into the shared Agent Skills root
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh \
| sh -s -- install --all --target "$HOME/.agents/skills"
# Unattended replacement: managed version transitions replace without backups,
# unmanaged changed files are still backed up as <file>.bak
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh \
| sh -s -- install --all --target "$HOME/.agents/skills" --yes
Every run ends with a summary block on stdout saying what was installed, what was not, and why; the progress and diagnostics go to stderr, so installer install … > summary.txt keeps the outcome and 2>/dev/null keeps it readable. A blocked skill is reported once — not once per root — with the commands that finish the job. This is what an --all run on a machine without rjq prints:
== Summary ==
Installed: /home/u/.agents/skills/project-specifics
Installed: /home/u/.agents/skills/resource-limited-testing
Installed: /home/u/.agents/skills/brainstorm
Installed: /home/u/.agents/skills/post-implementation-review
Skipped: planning — a hard requirement is missing, nothing was written
To install planning once its requirements are met:
1. install rjq:
sudo apt-get install -y rjq
2. replay this run:
installer install --skill planning --target /home/u/.agents/skills --yes
The install step is chosen for the detected platform and package manager, and the replay line carries the same target and flags as the run that printed it, naming the installer binary bootstrap.sh downloaded to run it. The exit status is non-zero, because four of five skills is a partial install and CI must not read it as success.
Dependencies are declared per skill, so one unsatisfiable dependency never stops the other skills from installing. Each skill ships a requires.tsv naming what it needs, on which platform and architecture, and how badly:
| Strength | Effect |
|---|---|
hard | The skill does not work without the tool. It is not installed, the run explains why and prints the replay commands, and the exit status is non-zero. |
soft | The skill works in a degraded form. It is installed, with a warning naming the tool and the capability that is lost. The exit status is unaffected. |
Currently:
planning requires rjq (hard, every platform) — without it validate-plan.sh refuses to run and the plan gates stop firing.resource-limited-testing names memlimit (soft, Apple Silicon macOS only) — see Supported platforms above for what the degraded path still does.No other skill has a runtime dependency.
| Code | Meaning |
|---|---|
| 0 | Everything requested was installed. Soft warnings do not change this. |
| 1 | A requested skill was blocked by a hard requirement, or any other error. |
| 2 | install-skill only: approval declined, nothing was written. |
| 3 | install-skill only: an unsafe collision (an existing file that is not a managed version upgrade, or a symlink). |
Codes 2 and 3 belong to the machine-facing install-skill subcommand that the planning skill's own tooling uses; the interactive, install --all, and install --skill paths only ever return 0 or 1.
Running the bare one-liner with no arguments, or installer interactive directly, opens a full-screen skill picker instead of the numbered menu:
| Key | Action |
|---|---|
↑/k, ↓/j, PageUp, PageDown, Home, End | move the cursor |
Enter / Space | toggle the skill under the cursor |
Tab / Shift-Tab | switch focus between the skill list and the info pane |
a / n | select all / select none |
d, r, m (info pane focused) | show dependency hints, re-verify requirements, cycle a skill's integration mode |
i | confirm and install the current selection |
q / Escape | quit without installing |
With neither --target nor --agent given, it also prompts to choose an auto-detected agent root, a saved custom directory, a new custom directory, or a for every listed root.
Review the installer before running it if you do not trust the source. Skills are instructions that may guide agents to run commands or access files.
install.sh retired in favor of a compiled Rust installer (src/installer/); installer/bootstrap.sh is the small, pure-POSIX-sh entry point (#!/usr/bin/env sh, no bash-only constructs) that detects the platform, downloads the matching release, and hands off to it — see CONTRIBUTING.md before editing either.
installer/bootstrap.sh always downloads a release archive, so it is not how a checkout installs its own local files. Build the installer and point it at the checkout with --source instead:
cargo build --release -p installer
./target/release/installer interactive --source .
--package dev (also read from $PACKAGE_SELECTION) ships the MODE: DEV files too — tests, maintainer docs — instead of filtering them out, for installing a working development copy rather than the prod set.
installer/bootstrap.sh itself accepts AI_SKILLS_REPO_URL (a different owner/repo to resolve GitHub's "latest release" redirect against) and AI_SKILLS_RELEASE_URL (an exact archive URL, bypassing that redirect entirely — how RELEASE.md verifies one specific tag).
benchmark/planning/runtime/ makes the worker/reviewer/analyzer launch, session-id extraction, and token telemetry pluggable per CLI: the active agent defaults to codex and is selected with BENCHMARK_AGENT (opencode, claude), with a shared launcher (lib-agent.sh) owning all setsid/timeout/process-group control. See benchmark/planning/runtime/README.md for the contract and first-time setup.resource-limited-testing's platform behaviour is described under Supported platforms; its SKILL.md documents each fallback in detail.Distributed under the MIT License.
hooks/register.tsx 361 lines1import type { Register } from 'claude-code'
2import { update } from 'claude-code'
3
4// The terminal-program profiles the interactive-shell skill ships and the agent
5// has written (appprofiles/*.md): the list is shown first, and a profile opens
6// as formatted text with a button back to the list. The chosen profile is kept
7// in $.state. Toggled by `enabled` in settings.json pluginConfigs["tui-hint-board"].options.
8
9const PANE = 'tui-hint-board'
10const TOOL = 'show_tui_profiles'
11const READ = 'read_tui_hint_board'
12const OPEN = 'open_tui_profile'
13const SECTION = 'scroll_tui_profile'
14const profile = { plugin: 'tui-hint-board', key: 'profile' } as const
15const filter = { plugin: 'tui-hint-board', key: 'filter' } as const
16
17type Fs = {
18 exists: (path: string) => Promise<boolean>
19 list: (path?: string) => Promise<{ name: string; kind: string }[]>
20 read: (path: string) => Promise<string>
21}
22
23// Inline markers the pane cannot draw are removed.
24function plain(text: string): string {
25 return text
26 .replace(/\*\*(.+?)\*\*/g, '$1')
27 .replace(/__(.+?)__/g, '$1')
28 .replace(/`([^`]+)`/g, '$1')
29}
30
31// The profile's Markdown, line by line: headings, table rows left as text, and
32// bullets as `•`.
33function lines(markdown: string): { style: 'heading' | 'body' | 'blank' | 'divider'; text: string }[] {
34 return markdown.split('\n').map(raw => {
35 const line = raw.trimEnd()
36 if (line.trim() === '') return { style: 'blank' as const, text: '' }
37 // Longer than any pane; the pane cuts it at its own edge.
38 if (/^\s*-{3,}\s*$/.test(line)) return { style: 'divider' as const, text: '─'.repeat(400) }
39 const heading = /^#{1,6}\s+(.*)$/.exec(line)
40 if (heading) return { style: 'heading' as const, text: plain(heading[1] ?? '') }
41 const bullet = /^(\s*)[-*]\s+(.*)$/.exec(line)
42 if (bullet) return { style: 'body' as const, text: `${bullet[1] ?? ''}• ${plain(bullet[2] ?? '')}` }
43 return { style: 'body' as const, text: plain(line) }
44 })
45}
46
47// Whether a profile name matches the filter the person typed: any case, any part of the name.
48function matches(name: string, query: string): boolean {
49 return name.toLowerCase().includes(query.trim().toLowerCase())
50}
51
52// A profile the board lists, with every folder it is written in: the agent's own
53// notes (appprofiles.d) and the shipped one (appprofiles). One name, one entry.
54type Profile = { name: string; dirs: string[] }
55
56// The profile names the board lists: the shipped profiles and the agent's own.
57async function listProfiles(fs: Fs, dirs: string[]): Promise<Profile[]> {
58 const found: Profile[] = []
59 for (const dir of dirs) {
60 if (!(await fs.exists(dir))) continue
61 for (const entry of await fs.list(dir)) {
62 if (entry.kind === 'file' && entry.name.endsWith('.md') && entry.name !== 'FORMAT.md') {
63 const name = entry.name.replace(/\.md$/, '')
64 const known = found.find(item => item.name === name)
65 if (known) known.dirs.push(dir)
66 else found.push({ name, dirs: [dir] })
67 }
68 }
69 }
70 return found.sort((a, b) => a.name.localeCompare(b.name))
71}
72
73// A profile's text: the shipped text, with the agent's notes for this system appended
74// under their own heading, so system-specific knowledge extends the seeded one.
75async function bodyOf(fs: Fs, item: Profile): Promise<string | null> {
76 let shippedText: string | null = null
77 let ownText: string | null = null
78 for (const dir of item.dirs) {
79 const text = await fs.read(`${dir}/${item.name}.md`).catch(() => null)
80 if (text === null) continue
81 // The marker line tells the reader the file is a profile; the board does not show it.
82 const body = text.replace(/^<!--[^\n]*-->\n?/, '').trim()
83 if (dir.endsWith('appprofiles.d')) ownText = body
84 else shippedText = body
85 }
86 if (shippedText !== null && ownText !== null) {
87 return `${shippedText}\n\n---\n\n## Added for this system\n\n${ownText}`
88 }
89 return shippedText ?? ownText
90}
91
92export const register: Register = (on, options) => {
93 if (options.enabled === false) return
94
95 on('session.start', async ($, e, next) => {
96 await $.command.register({
97 name: 'tui-hint-board',
98 description: 'Show the terminal-program profiles the agent has in a pane; `/tui-hint-board close` hides it',
99 })
100 await $.tool.register({
101 name: TOOL,
102 description: 'Show the terminal-program profiles the agent knows, in a pane, and return their names as text.',
103 inputSchema: { type: 'object', properties: {} },
104 })
105 await $.tool.register({
106 name: READ,
107 description: 'Read which terminal program profile the person has picked in the board, as text, without opening the pane.',
108 inputSchema: { type: 'object', properties: {} },
109 })
110 await $.tool.register({
111 name: SECTION,
112 description:
113 'Scroll the open profile in the board to one of its titled sections, by part of the section title (e.g. "Keys", "Added for this system"). Lists the section titles when none matches.',
114 inputSchema: {
115 type: 'object',
116 properties: { section: { type: 'string', description: 'Part of the section title to scroll to.' } },
117 required: ['section'],
118 },
119 })
120 await $.tool.register({
121 name: OPEN,
122 description:
123 'Open one terminal program profile in the board for the person to read, by its name (its file name without .md). Shows the shipped text with any notes for this system under "Added for this system".',
124 inputSchema: {
125 type: 'object',
126 properties: { name: { type: 'string', description: 'The profile name, e.g. less or lldb.' } },
127 required: ['name'],
128 },
129 })
130 return next(e)
131 })
132
133 on('command.run', { command: 'tui-hint-board' }, async ($, e) => {
134 if (e.args.trim().toLowerCase() === 'close') {
135 await $.ui.close({ id: PANE })
136 return { text: 'TUI hint board closed.' }
137 }
138 await $.ui.open({ id: PANE, title: 'Terminal program profiles', focus: true })
139 return { text: 'TUI hint board opened.' }
140 })
141
142 on('tool.call', { tool: `mcp__tui-hint-board__${TOOL}` }, async $ => {
143 const home = (await $.env.get('HOME')) ?? ''
144 const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
145 const fs: Fs = {
146 exists: path => $.fs.exists(path),
147 list: path => $.fs.list(path),
148 read: path => $.fs.read(path),
149 }
150 const shipped = `${xdg || `${home}/.config`}/tsch-ai-skills/appprofiles`
151 const own = `${xdg || `${home}/.config`}/tsch-ai-skills/appprofiles.d`
152 const names = await listProfiles(fs, [own, shipped])
153 await $.ui.open({ id: PANE, title: 'Terminal program profiles', focus: true })
154
155 return {
156 result: [
157 names.length === 0 ? 'No terminal program profiles yet.' : 'Terminal program profiles:',
158 ...names.map(item => item.name),
159 ].join('\n'),
160 }
161 })
162
163 // The agent's way to move the open profile to one of its sections: the section is
164 // the first heading whose title contains the words given. The reading view keys each
165 // heading, so the scroll lands it at the top of the pane.
166 on('tool.call', { tool: `mcp__tui-hint-board__${SECTION}` }, async ($, e) => {
167 const home = (await $.env.get('HOME')) ?? ''
168 const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
169 const fs: Fs = {
170 exists: path => $.fs.exists(path),
171 list: path => $.fs.list(path),
172 read: path => $.fs.read(path),
173 }
174 const shipped = `${xdg || `${home}/.config`}/tsch-ai-skills/appprofiles`
175 const own = `${xdg || `${home}/.config`}/tsch-ai-skills/appprofiles.d`
176 const everyone = await listProfiles(fs, [own, shipped])
177 const { value: picked } = await $.state.get(profile)
178 const chosen = everyone.find(item => item.name === picked)
179 const text = chosen ? await bodyOf(fs, chosen) : null
180 if (!chosen || text === null) {
181 return { result: 'No profile is open in the board. Open one first with open_tui_profile.' }
182 }
183 const headings = lines(text)
184 .map((line, index) => ({ line, index }))
185 .filter(item => item.line.style === 'heading')
186 const wanted = String(e.section ?? '').trim().toLowerCase()
187 const found = headings.find(item => item.line.text.toLowerCase().includes(wanted))
188 if (!found) {
189 return { result: `No section matching "${wanted}". Sections: ${headings.map(item => item.line.text).join(', ')}` }
190 }
191 await $.ui.open({ id: PANE, title: 'Terminal program profiles', focus: true })
192 await $.ui.scroll({ to: { key: `section-${found.index}` }, in: PANE, block: 'start' })
193 return { result: `Scrolled ${chosen.name} to "${found.line.text}".` }
194 })
195
196 // The agent's way to open a profile for the person: the board picks it and opens
197 // the pane, so the reading view shows it with the notes for this system.
198 on('tool.call', { tool: `mcp__tui-hint-board__${OPEN}` }, async ($, e) => {
199 const home = (await $.env.get('HOME')) ?? ''
200 const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
201 const fs: Fs = {
202 exists: path => $.fs.exists(path),
203 list: path => $.fs.list(path),
204 read: path => $.fs.read(path),
205 }
206 const shipped = `${xdg || `${home}/.config`}/tsch-ai-skills/appprofiles`
207 const own = `${xdg || `${home}/.config`}/tsch-ai-skills/appprofiles.d`
208 const names = await listProfiles(fs, [own, shipped])
209 const wanted = String(e.name ?? '').trim()
210 const found = names.find(item => item.name === wanted)
211 if (!found) {
212 return { result: `No profile named "${wanted}". Profiles: ${names.map(item => item.name).join(', ')}` }
213 }
214 await update($, profile, () => found.name)
215 await $.ui.open({ id: PANE, title: 'Terminal program profiles', focus: true })
216 return { result: `Opened the ${found.name} profile in the board.` }
217 })
218
219 // The agent's read of the board's pick: the profile the person chose, or none.
220 // Read-only; the pane is not opened.
221 on('tool.call', { tool: `mcp__tui-hint-board__${READ}` }, async $ => {
222 const home = (await $.env.get('HOME')) ?? ''
223 const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
224 const fs: Fs = {
225 exists: path => $.fs.exists(path),
226 list: path => $.fs.list(path),
227 read: path => $.fs.read(path),
228 }
229 const shipped = `${xdg || `${home}/.config`}/tsch-ai-skills/appprofiles`
230 const own = `${xdg || `${home}/.config`}/tsch-ai-skills/appprofiles.d`
231 const names = await listProfiles(fs, [own, shipped])
232 const { value: picked } = await $.state.get(profile)
233 const chosen = names.find(item => item.name === picked)
234 const lines = [
235 chosen ? `Picked in the board: ${chosen.name}` : 'Picked in the board: none (the list is shown)',
236 `Profiles available: ${names.length}`,
237 ]
238 if (chosen) lines.push(`Files: ${chosen.dirs.map(dir => `${dir}/${chosen.name}.md`).join(', ')}`)
239 // The text the person has highlighted in the pane, when they have: a part of the profile.
240 const live = await $.ui.selection().catch(() => undefined)
241 lines.push(`Highlighted in the board: ${live?.text ?? 'none'}`)
242 return { result: lines.join('\n') }
243 })
244
245 // The ring moving onto one of the last three profiles scrolls the list so the
246 // profile two below it sits at the bottom: the focused one stays third from the
247 // bottom, and the list moves up one with each step until the end of the list.
248 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
249 if (e.component === 'Pane' && e.element !== undefined) {
250 const home = (await $.env.get('HOME')) ?? ''
251 const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
252 const fs: Fs = {
253 exists: path => $.fs.exists(path),
254 list: path => $.fs.list(path),
255 read: path => $.fs.read(path),
256 }
257 const shipped = `${xdg || `${home}/.config`}/tsch-ai-skills/appprofiles`
258 const own = `${xdg || `${home}/.config`}/tsch-ai-skills/appprofiles.d`
259 const { value: query } = await $.state.get(filter)
260 const names = (await listProfiles(fs, [own, shipped])).filter(item => matches(item.name, query ?? ''))
261 const index = names.findIndex(item => item.name === e.element)
262 const last = names.length - 1
263 if (index >= 0 && index >= last - 2) {
264 const target = names[Math.min(index + 2, last)]
265 if (target) await $.ui.scroll({ to: { key: target.name }, in: PANE, block: 'end' })
266 }
267 }
268 return next(e)
269 })
270
271 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
272 const { Box, Button, Input, Text } = $.ui.resolve(e)
273 const home = (await $.env.get('HOME')) ?? ''
274 const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
275 const fs: Fs = {
276 exists: path => $.fs.exists(path),
277 list: path => $.fs.list(path),
278 read: path => $.fs.read(path),
279 }
280 const shipped = `${xdg || `${home}/.config`}/tsch-ai-skills/appprofiles`
281 const own = `${xdg || `${home}/.config`}/tsch-ai-skills/appprofiles.d`
282 const everyone = await listProfiles(fs, [own, shipped])
283 const { value: picked } = await $.state.get(profile)
284 const { value: query } = await $.state.get(filter)
285 const names = everyone.filter(item => matches(item.name, query ?? ''))
286 const chosen = everyone.find(item => item.name === picked)
287 const text = chosen ? await bodyOf(fs, chosen) : null
288
289 return (
290 <Box flexDirection="column">
291 <Box flexDirection="row" justifyContent="space-between">
292 <Text bold inverse>
293 Terminal program profiles
294 </Text>
295 </Box>
296 {!(chosen && text !== null) && (
297 <Input key="profile-filter" label="filter: " placeholder="type a program name" onSubmit={text => update($, filter, () => text)} />
298 )}
299 {!(chosen && text !== null) && !!query?.trim() && (
300 <Box flexDirection="row">
301 <Text>Active filter: </Text>
302 <Text inverse color="cyan">{` ${query.trim()} `}</Text>
303 <Text> </Text>
304 <Button variant="primary" onPress={() => update($, filter, () => '')}>clear filter</Button>
305 </Box>
306 )}
307 <Text> </Text>
308 {chosen && text !== null ? (
309 <Box flexDirection="column">
310 <Button variant="primary" autoFocus onPress={() => update($, profile, () => null)}>
311 back to profiles
312 </Button>
313 <Text dimColor>
314 {chosen.dirs.length > 1
315 ? 'shipped profile, with your notes for this system'
316 : chosen.dirs[0]?.endsWith('appprofiles.d')
317 ? 'your own profile'
318 : 'shipped profile'}
319 </Text>
320 <Text> </Text>
321 {lines(text).map((line, index) =>
322 line.style === 'blank' ? (
323 <Text key={index}> </Text>
324 ) : line.style === 'divider' ? (
325 <Text key={index} dimColor wrap="truncate">
326 {line.text}
327 </Text>
328 ) : line.style === 'heading' ? (
329 <Box key={`section-${index}`}>
330 <Text bold underline color="cyan">
331 {line.text}
332 </Text>
333 </Box>
334 ) : (
335 <Text key={index}>{line.text}</Text>
336 ),
337 )}
338 </Box>
339 ) : (
340 <Box flexDirection="column">
341 {names.length === 0 && <Text dimColor>No terminal program profiles yet.</Text>}
342 {names.map((item, index) => (
343 <Button
344 key={item.name}
345 autoFocus={index === 0 || undefined}
346 onPress={() => update($, profile, () => item.name)}
347 >
348 {item.name}
349 </Button>
350 ))}
351 </Box>
352 )}
353 <Text> </Text>
354 <Button role="dismiss" onPress={() => $.ui.close({ id: PANE })}>
355 Close
356 </Button>
357 </Box>
358 )
359 })
360}
361types/index.d.ts 7 lines1// The profile the board shows, by its file name without `.md`. Null means the list.
2declare module 'claude-code' {
3 interface PluginState {
4 'tui-hint-board': { profile: string | null; filter: string }
5 }
6}
7