Shows what nib runs and holds: a band above the prompt for nibash calls, jobs, long summaries and slow providers, a /nibash pane with their output, panes…

An MCP server that gives coding agents hashline file tools and shell, based on oh-my-pi's hashline format.
Reads and searches print a [PATH#TAG] header (a 4-hex hash of the whole file) and LINE:TEXT rows. Edits have to be made using those line numbers, instead of repeating the old text.
The shell (named nibash) is a bash interpreter whose own cat, grep, head, sed -n and similar print the same rows when being output to the model (outputs piped in to a file or other program remain the same as normal bash)
Lines printed by these tools are also marked as "seen", so the assistant can directly edit a file from grep results.
All of these features have been added to suit my needs and may or may not fit your workflow.
nib replaces a client's built-in file tools and shell. nib setup configures Claude Code and OpenCode, but only Claude Code has been battle tested.
All the rest of the README is generated with Claude
rg (ripgrep) on PATHbwrap for sandbox mode (Linux only)GOPRIVATE='github.com/11ways/*' go install github.com/11ways/nib/cmd/nib@latest
From a checkout, ./install.sh builds and installs to ~/.local/bin (or $GOBIN). Restart the client's nib server after updating. nib --version prints the version.
nib setup # in a project folder
nib setup --user # for every project
nib setup --dry-run # show what would change
nib setup only adds what is missing, so running it again changes nothing. It writes:
.mcp.json: the nib server..claude/settings.local.json: approval of that server, nib's hook (for Read, nib's own tools, SessionStart, PostCompact and UserPromptSubmit), deny rules for Bash, Grep, Glob, Edit and Write, and an allow rule for mcp__nib__nibash_status.opencode.json, if the project has one: the nib server with codemode: false, and deny rules for shell, read, grep, glob and edit.With --user the settings go to the Claude config folder's settings.json ($CLAUDE_CONFIG_DIR, else ~/.claude), the server is registered with claude mcp add --scope user, OpenCode's user opencode.json is updated if it exists, and the nibash mod is installed. Files that are not plain JSON are left alone.
Denying Bash also breaks a skill's ! command lines, and subagents inherit the deny; nibash is their shell.
These options are remembered in ~/.config/nib/setup.json and apply to every nib unless its environment overrides them; --decider=, --summary=, --opencode= and --provider NAME= clear them:
| Option | Effect |
|---|---|
--provider NAME=PROGRAM | host a provider's tools (Providers) |
--decider KIND | fixed, keywords or openrouter:MODEL,... |
--summary LIST | summarizers for long output |
--opencode PROGRAM | the OpenCode 2 binary opencode: summarizers run |
Any MCP client can run nib as a stdio server, started in the project directory or with NIB_DIRECTORY set. For Claude Code:
claude mcp add-json --scope user nib '{"type":"stdio","command":"nib","alwaysLoad":true}'
then deny Bash, Grep, Glob, Edit and Write in settings.json. Keep the built-in Read for PDFs and notebooks.
In Claude Code, MCP edits get no inline diff approval, are not covered by acceptEdits, checkpoints or /rewind, and hooks matching Edit|Write only run for them when they also match mcp__nib__.*.
| Tool | Input | Purpose |
|---|---|---|
read | paths[], optional maxLineLength, plain, collapse, info, full | Files, ranges, declarations, directories, images, revisions and SQLite tables. |
grep | pattern, optional paths[], glob, ignoreCase, literal, before, after, mode, noIgnore, maxLineLength, skip, replace | ripgrep search; match rows are edit anchors; replace previews a replacement. |
find | optional paths[], glob, minSize, newer, sort, noIgnore, skip | Lists files with line counts, sizes and ages. |
edit | input (a patch, or REAPPLY TOKEN) | Edits, creates, copies, moves and deletes files. |
write | path, content, optional tag | Creates a file, or overwrites one given its current tag. |
nibash | command, optional description, expect, timeout, background | Runs a command in nibash. |
nibash_status | optional tail, view, restore, summaries | Lists running commands and jobs; used by the nibash mod. |
skim | paths[], expect | Reads long files (logs, test reports) for what expect asks, without edit anchors. |
skim is offered only with nibash. Tool arguments are lenient: numbers as strings, another tool's argument names (file_path, offset, limit), and JSON lists sent as strings are accepted with a note.
[src/example.ts#3C4D]
1:export const enabled = false;
2:export const name = "example";
TAG is xxHash32 of the BOM-free text with trailing spaces, tabs and CRs stripped per line, masked to 16 bits (identical to oh-my-pi's file_hash). A path may carry a selector:
| Selector | Reads |
|---|---|
path:A, path:A-B, path:A+N, path:A-, path:-N, path:A-B,C-D | lines |
path:name, path:Type.member, path:a,b | declarations, with their comments and annotations |
path#TAG | what changed since that version (nib keeps 4 per file) |
path@REV | the file at a git revision; a folder gives the commit and its diff; path@A...B or path@A..B the diff between them, a folder's files first (.@main...topic:Foo.java for one file) |
file.db:TABLE, file.db:TABLE:-15, file.db:SELECT ... | rows of a SQLite database file, or a read-only query |
Declarations are found with tree-sitter for Java, Kotlin, TypeScript, TSX, JavaScript, Go, Python, Rust, C, C++ and Zig, and as blocks and snippets in Hawkeye (.hwk) templates.
Without a selector:
node_modules/ or dist/ over 4 KiB are skipped with their size.In Java and Kotlin, runs of 10 or more imports print as a package tree. A call's output is capped at 40 KiB, shared fairly between its files. Lines over maxLineLength (default 2000) print as [line N, chars 1-2000 of M]|..., which is not an anchor; maxLineLength goes up to 2 MiB. Text files are limited to 8 MiB. Images (PNG, JPEG, GIF, WebP) are returned as image content, scaled down to what the model sees; PDFs are refused.
plain: true prints lines without numbers or tag, strips color codes and collapses repeats; it has no size limit and suits logs.info: true prints one line of facts per file: lines, size, longest line, line endings, age.N~TEXT rows or one [lines 40-120 unchanged since you saw them] row; full: true prints them whole.skim reads a file as plain lines and shapes it like nibash's long output, for the expect given (did the build pass; if not, which tests failed and why). Rows are N|TEXT, numbered by file line, never anchors. Skimming again with the same expect shows only lines added since; with another expect, the lines already shown are left out.
2 matches in 2 files
[src/login.ts#1F2A]
4:import { session } from "./session"
*5:export function login(user: User) {
6: const token = session.create(user)
* marks matches; context rows default to 1 before and 1 after, at most 40 per page, given first to definitions; the note under a page past that budget gives the read selectors of the other matches' context, one per file, as in a.go:12-20,45-50. Pages hold 20 files (skip for more) and 20 matches per file (100 when one file matches). Every row shown whole is an edit anchor.
rg --hidden without .git/, with ripgrep's --engine auto, so look-around runs on PCRE2.noIgnore turns ignore files off. A path named in paths is searched even when it is ignored, and a search that finds nothing says which ignored files match, if a search through them ends within 3 seconds.app.log:1100-1250) to search only those lines.mode: "files" lists matching paths; mode: "count" counts per file.N|TEXT rows, which are not anchors.replace (ripgrep's syntax: $1, ${name}) previews changed lines as >N|TEXT rows and returns a token; edit with REAPPLY TOKEN applies exactly those lines.3 files, largest first
src/engine.ts (1796 lines, 84.2 KiB, 3m ago)
assets/logo.png (image/png, 12.0 KiB, 40d ago)
README.md (189 lines, 16.1 KiB, 1h ago)
Lists the files grep would search. glob filters by name, minSize (20k, 1.5M) and newer (30m, 2h, 3d) by size and age, sort is path, size or modified. Pages hold 200 files; a larger listing without skip shows folders with their file counts instead. Each folder is printed once, with its files indented under it.
[src/greet.ts#3C4D]
PUT 2.=2:
+ return `Hello, ${name}!`
PUT >$:
+export const version = 2
[src/old.ts#9F3E]
MV src/new.ts
[src/dead.ts#1A2B]
REM
*** Add File: src/created.ts
+export const created = true
| Hunk | Effect |
|---|---|
PUT N.=M: / PUT N: | Replace lines N..M (or line N) with the + rows below. |
PUT <N: / PUT >N: | Insert before / after line N. |
PUT >$: | Append at the end of the file; works without a tag. |
CUT N.=M / CUT N | Delete lines. |
PUT N*: / CUT N* | Replace / delete the block starting at line N. |
PUT >N*: | Insert after the block starting at line N. |
SUB N: OLD | Replace text OLD, which occurs once on line N, with the + rows; a lone + deletes it. |
~TEXT | Under a PUT or CUT: check that line N starts with TEXT; a second ~ checks line M. |
REM | Delete the file (to the trash). |
MV DEST | Move the file after the section's edits. |
CP DEST | Copy the file; the section's edits land only in the copy. |
IMPORT a.b.C, ... | Add Java or Kotlin imports where the file keeps its own. |
*** Add File: PATH | Create a file from the + rows. |
[PATH] | A header without a tag; allows only PUT >$: and IMPORT. |
~ check. Otherwise the edit is refused, showing those lines, with a token that applies it unchanged.SUB whose old text is not on its line but once in the file applies on that line, with a note, when it is at most 2 lines away and seen; further away or unseen, the edit is refused showing that line, and the same patch sent again (or its token) applies it there.N*) come from tree-sitter in the languages read parses, from indentation elsewhere.- rows that match the replaced lines are accepted.[lines 40-46 as you sent them].IMPORT hunk), a JSON file becomes invalid or repeats a key, or a YAML or TOML file stops parsing (with the line of its first error).Creates a file with its parents, or overwrites one given its current tag. Overwriting keeps a CRLF file's line endings and its BOM. The response is a sparse map of N~TEXT rows; every written line counts as seen, so it can be edited right away. A JSON, YAML or TOML file that was sound, or is new, is checked as an edit's is.
A bash interpreter (mvdan/sh). Every call starts afresh in the project directory: cd, variables and functions do not carry over, and calls run in parallel. Jobs and named directories do carry over. Programs run as real processes with empty stdin, PAGER=cat, GIT_PAGER=cat, TERM=dumb and GIT_TERMINAL_PROMPT=0. A command still running at timeout (default 2 minutes, at most 10) is not stopped; it goes on as a job.
cat, head, tail, grep, egrep, fgrep, rg, sed -n, cut -c and wc run as nib's own when their output reaches the model and nib supports their options; they print [PATH#TAG] blocks of anchored rows. Anything else, including those commands with an unsupported option, piped into a real program or redirected to a file, runs the system program. type cat names the system program.
status, diff, show, log, ls-files and the subcommands that change a repository run the real git and are reformatted: paths grouped by folder, --stat with exact counts, diffs as anchored rows, commit headers on one line (abbreviated hash, author email unless it is yours, local time to the minute), and hints and progress lines hidden. The full output stays in the log. A rebase, merge or pull that joins incoming commits with the branch's own ends with a note naming the files both sides changed (or none), and one that cannot fast-forward with how the branches diverged.git ARGS in every repository below the current directory, each repository's output under a ==> NAME <== header, or as NAME: LINE when it is one plain line, so git-each status -sb gives a clean repository's branch and how far it is ahead or behind on one line, and git-each log --oneline --branches --not --remotes the commits that are on no remote. {base} is the repository's base in nib.json, else origin/HEAD.rm, unlink, git rm, git clean, find -delete and edit's REM move files to the user's trash. trash list shows them, trash restore [-f] PATH puts one back.cd (or pushd, popd) ends the call unless the command line handles it (if, &&, ||), as under set -e.background: true or cmd & start a job; jobs, wait and kill work on them, and $! is a job spec (%3). Finished jobs are reported at the next call./dev/null, an earlier pipeline stage) are named in a closing line. A failed command whose stderr went to /dev/null has its first line quoted there, as in [exit 2, its stderr discarded: "cat: x: No such file or directory"]; the output itself stays without it.hash -d name=dir defines a named directory; bare hash -d lists them. A pipeline's last stage runs in the shell, so ... | read v sets v.Output over 400 rows or 30 KiB is cut and saved whole to a log in $XDG_RUNTIME_DIR/nib/ (or the temp directory). A decider picks the rows that stay; rows of a cut are numbered N|TEXT by log line, so read LOG:A-B plain:true shows a gap.
| Decider | Keeps |
|---|---|
keywords (default) | the first 30 and last 100 rows, plus rows naming errors or failures with 3 rows around each |
fixed | the first 100 and last 200 rows |
openrouter | keyword hits plus windows a decision model rates as relevant to the call's description and expect |
With NIB_DECIDER_SUMMARY, output over 150 lines or 12 KB is summarized instead: a <summary> of up to 3 sentences, then the log lines it points at, and for a failed call its last 20 lines. Summarizers are OpenRouter model ids, claude-p:MODEL (runs claude -p with no tools) or opencode:PROVIDER/MODEL (runs OpenCode 2 with an isolated data folder). /nibash summaries off or nibash_status's summaries turns them off for a session. With openrouter, output and commands are sent to OpenRouter with zero data retention requested.
Files named *.log or *.out, or in an untracked log, logs or test-logs folder, print as plain output when read through nibash, so they are cut and summarized too.
nib shape --log PATH --purpose TEXT [--exit N] [--command TEXT] prints what nibash would show of a saved log, for build and test wrappers.
A nib.json can forbid commands:
{"rules": [
{"program": ["gradlew", "gradle"], "why": "Gradle runs through zenit-dev's MCP tools"},
{"program": "zenit-dev", "args": "^(build|test)( |$)", "why": "use zd_build / zd_test"},
{"rule": "Do not run full test suites directly.", "match": "gradle|test", "why": "Use the zenit-dev MCP tools."},
{"rule": "Do not pipe or filter zenit-dev's output.", "program": "zenit-dev"}
]}
nibash checks every command a call may run, including inside pipelines, $(...), wrappers like env and xargs, and bash -c strings. program rules match by base name, args is a regex over the arguments. rule entries are judged by the decision model, with the programs the call runs, when match hits the command text and, if they name a program (and args), only when the call runs it; they are skipped without a model. A refused call runs nothing and exits 126. The nearest nib.json with rules at or above the call's directory applies.
nib shortens long paths with names: ~orm/workflow/Repo.java. Names work in every tool and, unquoted, in nibash; programs receive the real path. They come from:
nib.json in the server's directory: {"directories": {"orm": "path/to/orm"}}.src/<set>/java, src/<set>/kotlin) with a package prefix of 24 characters or more, unless "autoDirectories": false.hash -d name=dir in nibash, for that agent and the subagents it starts afterwards.~/.local/state/nib/names/ for later ones. NIB_AUTO_NAMES=0 turns this off.nib tracks what each agent has seen, per file version, to decide which lines an edit may anchor on. Claude Code subagents are told apart by nib's hook, OpenCode sessions by their session id; a fork starts with a copy of what its parent had seen. When an agent's context is compacted or cleared, what it saw before stops counting. What agents saw is kept across restarts in ~/.local/state/nib/seen/ as hashes and line numbers, never text.
The hook also refuses the built-in Read for files nib can read, naming the equivalent nib call; sending the same call again lets it through.
NIB_CONFIRM_OUTSIDE_ROOTS=1: writes outside the client's MCP roots ask the user through elicitation, and are refused without it. Reads are not restricted.NIB_SANDBOX=1: every tool, reads included, stays inside the roots, judged after resolving symlinks. nibash's programs run in bwrap with the system read-only, the roots writable, a private /tmp and no network (NIB_SANDBOX_NETWORK=1 allows it). Without bwrap nib refuses to start unless NIB_SHELL=0.nib --provider NAME=PROGRAM runs PROGRAM nib-provider, an MCP server on stdio, and offers its tools as NAME_TOOL (zemble's search becomes zemble_search). A provider answers with prose and locations, which nib prints as anchored rows of the file as it is now. The protocol is in docs/design-providers.md.
mods/nibash is a Claude Code mod, installed by nib setup --user. It shows running nibash calls and jobs in a band above the prompt and a toast when a job ends. Its slash commands open panes: /nibash (commands and their output), /trash (with restore buttons), /tokens (characters returned per agent and tool), /names and /seen.
| Variable | Default | Meaning |
|---|---|---|
NIB_DIRECTORY | working directory | the project directory |
NIB_SHELL | on | 0 turns nibash off |
NIB_SANDBOX | unset | 1 confines every tool to the roots |
NIB_SANDBOX_NETWORK | unset | 1 gives sandboxed programs the network |
NIB_SANDBOX_BIND | unset | directories (: separated) bound read-only in the sandbox |
NIB_PROVIDERS_SANDBOX | unset | 1 allows providers under NIB_SANDBOX=1 |
NIB_CONFIRM_OUTSIDE_ROOTS | unset | 1 asks before writes outside the roots |
NIB_AUTO_NAMES | on | 0 turns off automatic named directories |
NIB_CHANNEL | unset | 1 when Claude Code was started with nib's channel, so job ends wake the agent |
NIB_DECIDER | keywords | fixed, keywords or openrouter |
NIB_DECIDER_KEY | $OPENROUTER_API_KEY | OpenRouter key; else ~/.config/nib/openrouter-key, then OpenCode's login |
NIB_DECIDER_MODEL | typesafe/jev-1.14 | decision models, comma separated, in order of preference |
NIB_DECIDER_URL | https://openrouter.ai/api/alpha/decisions | decision endpoint |
NIB_DECIDER_TIMEOUT | 30000 | milliseconds per decision |
NIB_DECIDER_SUMMARY | unset | summarizers, comma separated, in order of preference |
NIB_DECIDER_SUMMARY_URL | https://openrouter.ai/api/v1/chat/completions | chat endpoint |
NIB_DECIDER_SUMMARY_TIMEOUT | 30000 | milliseconds per summary |
NIB_DECIDER_OPENCODE | opencode | the OpenCode 2 binary |
NIB_DECIDER_CACHE | unset | directory to cache decisions and summaries on disk |
NIB_DECIDER_SANDBOX | unset | 1 allows openrouter under NIB_SANDBOX=1 |
nib keeps its files under the home folder on every OS (XDG_* variables move them): settings in ~/.config/nib, state in ~/.local/state/nib, the mod in ~/.local/share/nib.
~/.Trash; on Windows nib uses a freedesktop-layout trash in ~/.local/share/Trash.kill supports HUP, INT, QUIT, KILL and TERM. nibash runs programs from PATH (e.g. Git for Windows), and process substitution is not available.go test ./... # Go unit tests
bun install
bun test # black-box suites against the binary over MCP
bun typecheck # go vet and tsgo --noEmit
gofmt -l .
bun bench/run.ts compares a harness's built-in tools with nib on real editing tasks through Claude Code and OpenCode. It makes paid model calls; --plan, --validate, --reparse, --recheck and --report do not. Results are stored in bench/results/ and reused.
See AGENTS.md for the source layout, docs/ for design notes.
hooks/register.tsx 728 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Command, Job, ModelCall, NameList, ProviderCall, SeenList, Status, Tokens, TrashEntry, Views } from '../types'
5
6/** The pane's id, and the slash command that opens it. */
7const PANE = 'nibash'
8/** How many output lines of each command and job the pane shows. */
9const TAIL = 12
10/** How often nib is asked while something runs. */
11const POLL_MS = 1000
12/** How many cards the band shows before it counts the rest. */
13const BAND_CARDS = 3
14/** How narrow a card may be; a band too narrow for cards side by side stacks them. */
15const CARD_MIN = 34
16/** How long a job that ended stays in the band, green for exit 0 and red otherwise, before it goes. */
17const ENDED_MS = 10_000
18/** How long a hosted provider's call runs before the band names it: a program that answers at once is not worth a line. */
19const PROVIDER_AFTER_MS = 3000
20
21const status = atom({ plugin: 'nibash', key: 'status' } as const, null)
22const server = atom({ plugin: 'nibash', key: 'server' } as const, 'nib')
23const views = atom({ plugin: 'nibash', key: 'views' } as const, {} as Views)
24
25/** A nibash call of any nib server, as Claude Code names it: mcp__<server>__nibash. */
26const NIBASH = /^mcp__(.+)__nibash$/
27/** A hosted provider's tool of a nib server, as Claude Code names it: mcp__<server>__<provider>_<tool>. */
28const HOSTED = /^mcp__(.+?)__([a-z][a-z0-9-]*)_(.+)$/
29/** Any MCP server's tool, as Claude Code names it: mcp__<server>__<tool>. */
30const MCP_TOOL = /^mcp__(.+?)__(.+)$/
31
32/** The panes that show one list of nib's, each opened by the slash command of its name; the list is nibash_status's view of that name. */
33const VIEWS = {
34 trash: 'Show what this session moved to the trash, and put one back',
35 tokens: 'Show how many characters nib returned in this session, by agent and tool',
36 names: 'Show the named directories each agent has, where each came from and how often it was used',
37 seen: 'Show the lines nib counts each agent as having seen of its files',
38} as const
39type ViewName = keyof typeof VIEWS
40const VIEW_NAMES = Object.keys(VIEWS) as ViewName[]
41/** How often the lists of open view panes are asked again. */
42const VIEW_MS = 2000
43
44/** A duration as m:ss, or h:mm:ss from an hour on. */
45export function elapsed(ms: number): string {
46 const seconds = Math.max(0, Math.floor(ms / 1000))
47 const pad = (n: number) => String(n).padStart(2, '0')
48 if (seconds >= 3600) return `${Math.floor(seconds / 3600)}:${pad(Math.floor(seconds / 60) % 60)}:${pad(seconds % 60)}`
49 return `${Math.floor(seconds / 60)}:${pad(seconds % 60)}`
50}
51
52/** A command line on one line, cut to length. */
53function short(command: string, length = 60): string {
54 const line = command.replace(/\s+/g, ' ').trim()
55 return line.length > length ? `${line.slice(0, length - 3)}...` : line
56}
57
58const isStrings = (value: unknown): value is string[] => Array.isArray(value) && value.every(v => typeof v === 'string')
59const isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
60
61function command(value: unknown): Command | undefined {
62 const v = value as Record<string, unknown> | null
63 if (typeof v !== 'object' || v === null || typeof v.agent !== 'string' || typeof v.command !== 'string' || !isNumber(v.started)) return undefined
64 if ((v.description !== undefined && typeof v.description !== 'string') || (v.tail !== undefined && !isStrings(v.tail))) return undefined
65 return { agent: v.agent, command: v.command, description: v.description as string | undefined, started: v.started, tail: v.tail as string[] | undefined }
66}
67
68function job(value: unknown): Job | undefined {
69 const v = value as Record<string, unknown> | null
70 if (typeof v !== 'object' || v === null || !isNumber(v.id) || typeof v.agent !== 'string' || typeof v.command !== 'string') return undefined
71 if (!isNumber(v.started) || typeof v.running !== 'boolean' || typeof v.log !== 'string') return undefined
72 if ((v.exit !== undefined && !isNumber(v.exit)) || (v.ended !== undefined && !isNumber(v.ended)) || (v.tail !== undefined && !isStrings(v.tail))) return undefined
73 return { id: v.id, agent: v.agent, command: v.command, started: v.started, ended: v.ended as number | undefined, running: v.running, exit: v.exit as number | undefined, log: v.log, tail: v.tail as string[] | undefined }
74}
75
76/** A decision or summary model nib is asking, as nibash_status gives it. */
77function modelCall(value: unknown): ModelCall | undefined {
78 const v = value as Record<string, unknown> | null
79 if (typeof v !== 'object' || v === null || typeof v.kind !== 'string' || typeof v.model !== 'string' || !isNumber(v.started)) return undefined
80 return { kind: v.kind, model: v.model, started: v.started }
81}
82
83/** A hosted provider's tool call running, as nibash_status gives it. */
84function providerCall(value: unknown): ProviderCall | undefined {
85 const v = value as Record<string, unknown> | null
86 if (typeof v !== 'object' || v === null || typeof v.agent !== 'string' || typeof v.tool !== 'string' || !isNumber(v.started)) return undefined
87 return { agent: v.agent, tool: v.tool, started: v.started }
88}
89
90/** Reads a nibash_status answer; undefined for anything else, so a server that is not nib, or another version of it, shows nothing. */
91export function parse(content: unknown, at: number): Status | undefined {
92 const v = content as Record<string, unknown> | null
93 if (typeof v !== 'object' || v === null || !Array.isArray(v.commands) || !Array.isArray(v.jobs)) return undefined
94 const commands = v.commands.map(command)
95 const jobs = v.jobs.map(job)
96 if (commands.some(c => c === undefined) || jobs.some(j => j === undefined)) return undefined
97 const status: Status = { commands: commands as Command[], jobs: jobs as Job[], at }
98 // Answers of an older nib hold neither; one that does is read in full or not at all.
99 if (v.models !== undefined) {
100 const models = Array.isArray(v.models) ? v.models.map(modelCall) : undefined
101 if (models === undefined || models.some(m => m === undefined)) return undefined
102 status.models = models as ModelCall[]
103 }
104 if (v.summaries !== undefined) {
105 const on = (v.summaries as { on?: unknown } | null)?.on
106 if (typeof on !== 'boolean') return undefined
107 status.summaries = on
108 }
109 if (v.calls !== undefined) {
110 const calls = Array.isArray(v.calls) ? v.calls.map(providerCall) : undefined
111 if (calls === undefined || calls.some(c => c === undefined)) return undefined
112 status.calls = calls as ProviderCall[]
113 }
114 return status
115}
116
117/** The trash a nibash_status answer lists; undefined when it lists none or not in nib's shape. */
118export function parseTrash(content: unknown): TrashEntry[] | undefined {
119 const trash = (content as { trash?: { entries?: unknown } } | null)?.trash
120 if (typeof trash !== 'object' || trash === null || !Array.isArray(trash.entries)) return undefined
121 const entries: TrashEntry[] = []
122 for (const value of trash.entries) {
123 const v = value as Record<string, unknown> | null
124 if (typeof v !== 'object' || v === null || typeof v.path !== 'string' || typeof v.shown !== 'string' || !isNumber(v.when)) return undefined
125 if (v.dir !== undefined && typeof v.dir !== 'boolean') return undefined
126 entries.push({ path: v.path, shown: v.shown, when: v.when, dir: v.dir as boolean | undefined })
127 }
128 return entries
129}
130
131/** What nib said of a restore it was asked for: the line to show; undefined when the answer holds none. */
132export function parseRestored(content: unknown): { done: boolean; message: string } | undefined {
133 const restore = (content as { restore?: Record<string, unknown> } | null)?.restore
134 if (typeof restore !== 'object' || restore === null || typeof restore.done !== 'boolean' || typeof restore.message !== 'string') return undefined
135 return { done: restore.done, message: restore.message }
136}
137
138const isCount = (value: unknown): value is number => isNumber(value) && value >= 0
139
140/** The token report a nibash_status answer holds; undefined when it holds none in nib's shape. */
141export function parseTokens(content: unknown): Tokens | undefined {
142 const v = (content as { tokens?: Record<string, unknown> } | null)?.tokens
143 if (typeof v !== 'object' || v === null || !isCount(v.chars) || !isCount(v.calls) || !Array.isArray(v.agents) || !Array.isArray(v.largest)) return undefined
144 const agents: Tokens['agents'] = []
145 for (const a of v.agents as Record<string, unknown>[]) {
146 if (typeof a !== 'object' || a === null || typeof a.agent !== 'string' || !isCount(a.calls) || !isCount(a.chars) || !Array.isArray(a.tools)) return undefined
147 const tools: Tokens['agents'][number]['tools'] = []
148 for (const t of a.tools as Record<string, unknown>[]) {
149 if (typeof t !== 'object' || t === null || typeof t.tool !== 'string' || !isCount(t.calls) || !isCount(t.chars)) return undefined
150 tools.push({ tool: t.tool, calls: t.calls, chars: t.chars })
151 }
152 agents.push({ agent: a.agent, calls: a.calls, chars: a.chars, tools })
153 }
154 const largest: Tokens['largest'] = []
155 for (const b of v.largest as Record<string, unknown>[]) {
156 if (typeof b !== 'object' || b === null || typeof b.agent !== 'string' || typeof b.tool !== 'string' || !isCount(b.chars) || typeof b.call !== 'string') return undefined
157 largest.push({ agent: b.agent, tool: b.tool, chars: b.chars, call: b.call })
158 }
159 return { chars: v.chars, calls: v.calls, agents, largest }
160}
161
162/** The named directories a nibash_status answer lists, per agent; undefined when it lists none in nib's shape. */
163export function parseNames(content: unknown): NameList | undefined {
164 const v = (content as { names?: { agents?: unknown } } | null)?.names
165 if (typeof v !== 'object' || v === null || !Array.isArray(v.agents)) return undefined
166 const agents: NameList = []
167 for (const a of v.agents as Record<string, unknown>[]) {
168 if (typeof a !== 'object' || a === null || typeof a.agent !== 'string' || !Array.isArray(a.names)) return undefined
169 const rows: NameList[number]['names'] = []
170 for (const n of a.names as Record<string, unknown>[]) {
171 if (typeof n !== 'object' || n === null || typeof n.name !== 'string' || typeof n.directory !== 'string' || typeof n.source !== 'string' || !isCount(n.uses)) return undefined
172 rows.push({ name: n.name, directory: n.directory, source: n.source, uses: n.uses })
173 }
174 agents.push({ agent: a.agent, names: rows })
175 }
176 return agents
177}
178
179/** The seen lines a nibash_status answer lists, per agent; undefined when it lists none in nib's shape. */
180export function parseSeen(content: unknown): SeenList | undefined {
181 const v = (content as { seen?: { agents?: unknown } } | null)?.seen
182 if (typeof v !== 'object' || v === null || !Array.isArray(v.agents)) return undefined
183 const agents: SeenList = []
184 for (const a of v.agents as Record<string, unknown>[]) {
185 if (typeof a !== 'object' || a === null || typeof a.agent !== 'string' || !Array.isArray(a.files) || (a.more !== undefined && !isCount(a.more))) return undefined
186 const files: SeenList[number]['files'] = []
187 for (const f of a.files as Record<string, unknown>[]) {
188 if (typeof f !== 'object' || f === null || typeof f.path !== 'string' || typeof f.tag !== 'string' || !isCount(f.lines) || !Array.isArray(f.seen)) return undefined
189 if ((f.anywhere !== undefined && typeof f.anywhere !== 'boolean') || (f.known !== undefined && !isCount(f.known)) || (f.moreRanges !== undefined && !isCount(f.moreRanges))) return undefined
190 const seen: [number, number][] = []
191 for (const range of f.seen as unknown[]) {
192 if (!Array.isArray(range) || range.length !== 2 || !isCount(range[0]) || !isCount(range[1])) return undefined
193 seen.push([range[0], range[1]])
194 }
195 files.push({ path: f.path, tag: f.tag, lines: f.lines, anywhere: f.anywhere as boolean | undefined, seen, moreRanges: f.moreRanges as number | undefined, known: f.known as number | undefined })
196 }
197 agents.push({ agent: a.agent, files, more: a.more as number | undefined })
198 }
199 return agents
200}
201
202/** The part of Views a view's answer fills; undefined when the answer holds that list in no shape of nib's. */
203export function parseView(name: ViewName, content: unknown): Partial<Views> | undefined {
204 switch (name) {
205 case 'trash': {
206 const trash = parseTrash(content)
207 return trash === undefined ? undefined : { trash }
208 }
209 case 'tokens': {
210 const tokens = parseTokens(content)
211 return tokens === undefined ? undefined : { tokens }
212 }
213 case 'names': {
214 const names = parseNames(content)
215 return names === undefined ? undefined : { names }
216 }
217 case 'seen': {
218 const seen = parseSeen(content)
219 return seen === undefined ? undefined : { seen }
220 }
221 }
222}
223
224/** A moment as the time of day. */
225export function clock(ms: number): string {
226 const d = new Date(ms)
227 const pad = (n: number) => String(n).padStart(2, '0')
228 return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
229}
230
231/** A count with its thousands set apart. */
232export function count(n: number): string {
233 return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
234}
235
236/** One row of the pane, or card of the band: a running command, a model or hosted call, or a job; exit is set once a job ended. */
237type Row = { key: string; label: string; time: string; title: string; command?: string; agent?: string; tail: string[]; isDone: boolean; exit?: number }
238
239/** A row's agent as it shows: nothing for the main one. */
240const who = (agent: string) => (agent === 'main' ? undefined : 'subagent')
241
242/** How a command is looked up among the descriptions: spaces collapsed, and a final & spaced as nib prints a job's. */
243export const said = (command: string) => command.replace(/\s+/g, ' ').trim().replace(/\s*&$/, ' &')
244
245/**
246 * The rows a status shows.
247 * @param ended which ended jobs show: all with true, none with false, or those that ended less than that many milliseconds before
248 * @param waiting the commands of nibash calls still in their request, which the transcript already shows; one row each is left out
249 * @param described the descriptions of nibash calls the mod saw, by said(command): a job carrying on such a call is titled by it
250 */
251export function rows(s: Status, ended: boolean | number, waiting: readonly string[] = [], described: ReadonlyMap<string, string> = new Map()): Row[] {
252 const left = [...waiting]
253 const out: Row[] = []
254 s.commands.forEach((c, index) => {
255 const at = left.indexOf(c.command)
256 if (at >= 0) {
257 left.splice(at, 1)
258 return
259 }
260 out.push({
261 key: `c${index}:${c.agent}:${c.started}`,
262 label: 'nibash',
263 time: elapsed(s.at - c.started),
264 title: c.description || short(c.command),
265 command: c.description ? short(c.command) : undefined,
266 agent: who(c.agent),
267 tail: c.tail ?? [],
268 isDone: false,
269 })
270 })
271 for (const m of s.models ?? []) {
272 out.push({
273 key: `m:${m.kind}:${m.model}:${m.started}`,
274 label: ['summary', 'shape', 'rules'].includes(m.kind) ? m.kind : 'decider',
275 time: elapsed(s.at - m.started),
276 title: m.model,
277 tail: [],
278 isDone: false,
279 })
280 }
281 for (const c of s.calls ?? []) {
282 if (s.at - c.started < PROVIDER_AFTER_MS) continue
283 const [provider, ...tool] = c.tool.split('_')
284 out.push({
285 key: `p:${c.agent}:${c.tool}:${c.started}`,
286 label: provider ?? c.tool,
287 time: elapsed(s.at - c.started),
288 title: tool.join('_') || c.tool,
289 agent: who(c.agent),
290 tail: [],
291 isDone: false,
292 })
293 }
294 for (const j of s.jobs) {
295 if (!j.running && (ended === false || (typeof ended === 'number' && s.at - (j.ended ?? s.at) >= ended))) continue
296 out.push({
297 key: `j${j.id}:${j.agent}`,
298 label: `job %${j.id}`,
299 time: j.running ? elapsed(s.at - j.started) : `exit ${j.exit ?? '?'} after ${elapsed((j.ended ?? s.at) - j.started)}`,
300 title: described.get(said(j.command)) || short(j.command),
301 command: described.has(said(j.command)) ? short(j.command) : undefined,
302 agent: who(j.agent),
303 tail: j.tail ?? [],
304 isDone: !j.running,
305 exit: j.exit,
306 })
307 }
308 return out
309}
310
311/** The structured content Claude Code passes on in its place: the JSON of it as the answer's one text block; undefined for anything else. */
312export function structuredText(content: readonly unknown[]): unknown {
313 const block = content[0] as { type?: unknown; text?: unknown } | undefined
314 if (content.length !== 1 || block?.type !== 'text' || typeof block.text !== 'string') return undefined
315 try {
316 return JSON.parse(block.text)
317 } catch {
318 return undefined
319 }
320}
321
322// What runs is nib's to say: the hooks only decide when to ask. A reload starts these over, and the next poll restores them.
323let inFlight = 0
324let timer: Timer | undefined
325let asking = false
326const watched = new Set<string>()
327/** The commands of nibash calls still in their request; Claude Code moving one to the background ends its request. */
328const waiting: string[] = []
329/**
330 * The description of each nibash call the mod saw, any agent's, by said(command), the newest last: nib's status leaves descriptions out, since
331 * an agent reading it would pay for them, so the band takes them from the calls; a job a call became, or its one cmd &, has its command.
332 */
333const described = new Map<string, string>()
334/** How many descriptions are kept. */
335const DESCRIBED = 100
336
337function stop(): void {
338 timer?.cancel()
339 timer = undefined
340}
341
342/** Asks nib what runs, keeps the answer for the drawings, toasts the jobs that ended since, and stops asking once nothing runs. */
343async function poll($: EngineInterface): Promise<void> {
344 if (asking) return
345 asking = true
346 try {
347 const name = await read($, server)
348 const answer = await $.mcp.call(name, 'nibash_status', { tail: TAIL })
349 const next = answer.isError ? undefined : parse(answer.structuredContent ?? structuredText(answer.content), await $.clock.now())
350 if (next === undefined) {
351 stop()
352 return
353 }
354 for (const j of next.jobs) {
355 const key = `${j.agent}:${j.id}`
356 if (j.running) {
357 watched.add(key)
358 } else if (watched.delete(key)) {
359 $.ui.toast(`nibash job %${j.id} ended, exit ${j.exit ?? '?'}: ${described.get(said(j.command)) || short(j.command, 50)}`)
360 }
361 }
362 await update($, status, () => next)
363 // A job that just ended stays in the band a while, so nib is asked until it has gone.
364 const fresh = next.jobs.some(j => !j.running && next.at - (j.ended ?? 0) < ENDED_MS)
365 if (inFlight === 0 && next.commands.length === 0 && !(next.models?.length) && !(next.calls?.length) && !next.jobs.some(j => j.running) && !fresh) stop()
366 } catch {
367 // No nib server by that name (yet), or it went away: nothing to show until a nibash call names one.
368 stop()
369 } finally {
370 asking = false
371 }
372}
373
374/** Asks now, and every POLL_MS until poll stops it. */
375function watch($: EngineInterface): void {
376 timer ??= $.clock.every(POLL_MS, () => void poll($))
377 void poll($)
378}
379
380let viewTimer: Timer | undefined
381
382/** Asks nib for a list, and for a restore when given; the structured answer, undefined when nib gave none in its shape. */
383async function ask($: EngineInterface, view: ViewName, extra: Record<string, unknown> = {}): Promise<unknown> {
384 const answer = await $.mcp.call(await read($, server), 'nibash_status', { view, ...extra })
385 return answer.isError ? undefined : (answer.structuredContent ?? structuredText(answer.content))
386}
387
388/** Asks nib again for the list of every open view pane, and lets the timer go once none is open. */
389async function refresh($: EngineInterface): Promise<void> {
390 try {
391 const open = (await $.ui.panes()).map(pane => pane.id).filter((id): id is ViewName => (VIEW_NAMES as string[]).includes(id))
392 if (open.length === 0) {
393 viewTimer?.cancel()
394 viewTimer = undefined
395 return
396 }
397 for (const name of open) {
398 const got = parseView(name, await ask($, name))
399 if (got !== undefined) await update($, views, v => ({ ...v, ...got }))
400 }
401 } catch {
402 // nib did not answer: the pane keeps what it has.
403 }
404}
405
406/** Asks now, and every VIEW_MS while a view pane is open. */
407function watchViews($: EngineInterface): void {
408 viewTimer ??= $.clock.every(VIEW_MS, () => void refresh($))
409 void refresh($)
410}
411
412/** Puts a trashed path back through nib's own trash code, and says what nib answered. */
413async function restore($: EngineInterface, path: string): Promise<void> {
414 try {
415 const content = await ask($, 'trash', { restore: path })
416 const trash = parseTrash(content)
417 const done = parseRestored(content)
418 await update($, views, v => ({ ...v, trash: trash ?? v.trash, note: done?.message }))
419 if (done !== undefined) $.ui.toast(done.message)
420 } catch {
421 $.ui.toast('nib did not answer the restore')
422 }
423}
424
425/** /nibash summaries [on|off]: turns nib's summaries of long output on or off, or flips them; the line to show of what nib answered. */
426async function setSummaries($: EngineInterface, args: string): Promise<string> {
427 const [word, setting] = args.split(/\s+/)
428 if (word !== 'summaries' || (setting !== undefined && setting !== 'on' && setting !== 'off')) return 'usage: /nibash summaries [on|off]'
429 const none = 'nib has no summary model set, so there are no summaries to turn on or off.'
430 try {
431 const name = await read($, server)
432 const ask = async (extra: Record<string, unknown>) => {
433 const answer = await $.mcp.call(name, 'nibash_status', extra)
434 return answer.isError ? undefined : parse(answer.structuredContent ?? structuredText(answer.content), await $.clock.now())
435 }
436 let on = setting === undefined ? undefined : setting === 'on'
437 if (on === undefined) {
438 const now = await ask({})
439 if (now?.summaries === undefined) return none
440 on = !now.summaries
441 }
442 const done = await ask({ summaries: on })
443 if (done?.summaries === undefined) return none
444 const summaries = done.summaries
445 await update($, status, s => (s === null ? s : { ...s, summaries }))
446 return summaries ? 'Summaries of long nibash output are on.' : 'Summaries of long nibash output are off: long output is cut as without a summary model.'
447 } catch {
448 return 'nib did not answer.'
449 }
450}
451
452export const register: Register = on => {
453
454 on('session.start', async ($, e, next) => {
455 await $.command.register({ name: PANE, description: 'Show the nibash commands and jobs nib runs, with their output' })
456 for (const name of VIEW_NAMES) await $.command.register({ name, description: VIEWS[name] })
457 const started = await next(e)
458 // After a reload jobs may still run; one look finds them.
459 watch($)
460 return started
461 })
462
463 // /nibash again, or Escape while the pane holds the keys, closes it.
464 on('command.run', { command: PANE }, async ($, e) => {
465 const args = (e.args ?? '').trim()
466 if (args !== '') return { text: await setSummaries($, args) }
467 if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
468 await $.ui.close({ id: PANE })
469 return {}
470 }
471 watch($)
472 await $.ui.open({ id: PANE, title: 'nibash', focus: true, closeOnEscape: true })
473 return {}
474 })
475
476 // Each list is a pane of its own, opened and closed by its slash command as /nibash is.
477 for (const name of VIEW_NAMES) {
478 on('command.run', { command: name }, async $ => {
479 if ((await $.ui.panes()).some(pane => pane.id === name)) {
480 await $.ui.close({ id: name })
481 return {}
482 }
483 await $.ui.open({ id: name, title: name, focus: true, closeOnEscape: true })
484 watchViews($)
485 return {}
486 })
487 }
488
489 on('tool.call', async ($, e, next) => {
490 const match = NIBASH.exec(e.tool)
491 if (match === null) {
492 // A hosted provider's tool, mcp__nib__zemble_search, may take long to start or to answer, and another MCP server's tool, a zenit-dev
493 // test run, may hand nib a log to shape through nib shape: nib reports both while they run. nib's own file tools answer at once.
494 const other = MCP_TOOL.exec(e.tool)
495 if (other === null || e.tool.endsWith('__nibash_status')) return next(e)
496 const own = other[1] === (await read($, server))
497 if (own && !HOSTED.test(e.tool)) return next(e)
498 inFlight += 1
499 watch($)
500 try {
501 return await next(e)
502 } finally {
503 inFlight -= 1
504 void poll($)
505 }
506 }
507 await update($, server, () => match[1])
508 const command = (e as { command?: unknown }).command
509 if (typeof command === 'string') waiting.push(command)
510 const description = (e as { description?: unknown }).description
511 if (typeof command === 'string' && typeof description === 'string' && description.trim() !== '') {
512 described.delete(said(command))
513 described.set(said(command), description.trim())
514 if (described.size > DESCRIBED) described.delete(described.keys().next().value as string)
515 }
516 inFlight += 1
517 watch($)
518 try {
519 return await next(e)
520 } finally {
521 inFlight -= 1
522 if (typeof command === 'string') waiting.splice(waiting.indexOf(command), 1)
523 void poll($)
524 }
525 })
526
527 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
528 const s = await read($, status)
529 const shown = s === null ? [] : rows(s, ENDED_MS, waiting, described)
530 if (e.props.hasSurvey || shown.length === 0) return next(e)
531 const { Box, Text } = $.ui.resolve(e)
532 const columns = e.props.bodyColumns > 0 ? e.props.bodyColumns : 80
533 // Side by side as many as are at least CARD_MIN wide, the rest below, and no more rows of cards than the band has room for.
534 const across = Math.max(1, Math.min(BAND_CARDS, Math.floor((columns + 1) / (CARD_MIN + 1))))
535 const fit = Math.max(1, Math.floor((e.props.maxRows - 1) / 5)) * across
536 const cards = shown.slice(0, Math.min(BAND_CARDS, fit))
537 const more = shown.length - cards.length
538 const width = Math.floor((columns - (Math.min(cards.length, across) - 1)) / Math.min(cards.length, across))
539 const lines: Row[][] = []
540 for (let at = 0; at < cards.length; at += across) lines.push(cards.slice(at, at + across))
541 return (
542 <Box flexDirection="column">
543 {lines.map((line, index) => (
544 <Box key={`line:${index}`} flexDirection="row" columnGap={1} alignItems="flex-start">
545 {line.map(row => {
546 const tone = row.isDone ? (row.exit === 0 ? 'success' : 'error') : 'suggestion'
547 const last = row.tail.at(-1)
548 return (
549 <Box key={row.key} flexDirection="column" borderStyle="round" borderColor={tone} paddingX={1} width={width} flexShrink={0}>
550 <Box flexDirection="row">
551 <Box flexGrow={1} flexShrink={1}>
552 <Text wrap="truncate-end">
553 <Text color={tone}>{row.label}</Text> <Text dimColor tabular>{row.time}</Text> <Text weight="semibold">{row.title}</Text>
554 </Text>
555 </Box>
556 {row.agent ? <Text dimColor> {row.agent}</Text> : null}
557 </Box>
558 {row.command ? <Text dimColor wrap="truncate-end">{row.command}</Text> : null}
559 {last ? <Text dimColor italic wrap="truncate-end">{last}</Text> : null}
560 </Box>
561 )
562 })}
563 </Box>
564 ))}
565 <Text dimColor>{more > 0 ? `+${more} more in /${PANE}` : `/${PANE} shows the full output`}</Text>
566 </Box>
567 )
568 })
569
570 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
571 const { Box, Text } = $.ui.resolve(e)
572 const s = await read($, status)
573 const all = s === null ? [] : rows(s, true, [], described)
574 if (all.length === 0) {
575 return (
576 <Box flexDirection="column">
577 {s?.summaries === undefined ? null : <Text dimColor>summaries are {s.summaries ? 'on; /nibash summaries off turns them off' : 'off; /nibash summaries on turns them on'}</Text>}
578 <Text dimColor>Nothing runs in nibash, and no job ended in the last 10 minutes.</Text>
579 </Box>
580 )
581 }
582 // Each row gets a share of the pane's height for its output, the running ones first.
583 const room = Math.max(all.length * 2, (e.viewport?.rows ?? 24) - 2)
584 const lines = Math.max(0, Math.floor(room / all.length) - 1)
585 return (
586 <Box flexDirection="column">
587 {s?.summaries === undefined ? null : <Text dimColor>summaries are {s.summaries ? 'on; /nibash summaries off turns them off' : 'off; /nibash summaries on turns them on'}</Text>}
588 {all.map(row => (
589 <Box key={row.key} flexDirection="column">
590 <Text wrap="truncate-end" dimColor={row.isDone}>
591 <Text color={row.isDone ? undefined : 'cyan'}>{row.label}</Text> <Text dimColor>{row.time}</Text> {row.title}
592 {row.command ? <Text dimColor> {row.command}</Text> : null}
593 {row.agent ? <Text dimColor> {row.agent}</Text> : null}
594 </Text>
595 {row.tail.slice(-lines).map((line, index) => (
596 <Text key={`${row.key}:${index}`} dimColor wrap="truncate-end">
597 {' '}
598 {line}
599 </Text>
600 ))}
601 </Box>
602 ))}
603 </Box>
604 )
605 })
606
607 on('ui.render', { component: 'Pane', requestId: 'trash' }, async ($, e) => {
608 const { Box, Text, Button } = $.ui.resolve(e)
609 const v = await read($, views)
610 const entries = v.trash ?? []
611 return (
612 <Box flexDirection="column">
613 {v.note ? <Text>{v.note}</Text> : null}
614 {entries.length === 0 ? <Text dimColor>Nothing this session moved to the trash is still there.</Text> : null}
615 {entries.map(entry => (
616 <Box key={`entry:${entry.path}`} flexDirection="row">
617 <Button key={`restore:${entry.path}`} label="restore" onPress={() => void restore($, entry.path)} />
618 <Text wrap="truncate-end">
619 {' '}
620 <Text dimColor>{clock(entry.when)}</Text> {entry.shown}
621 {entry.dir ? <Text dimColor> (directory)</Text> : null}
622 </Text>
623 </Box>
624 ))}
625 </Box>
626 )
627 })
628
629 on('ui.render', { component: 'Pane', requestId: 'tokens' }, async ($, e) => {
630 const { Box, Text } = $.ui.resolve(e)
631 const t = (await read($, views)).tokens
632 if (t === undefined || t.calls === 0) {
633 return (
634 <Box flexDirection="column">
635 <Text dimColor>No tool call of nib has returned anything in this session yet.</Text>
636 </Box>
637 )
638 }
639 return (
640 <Box flexDirection="column">
641 <Text>
642 <Text color="cyan">{count(t.chars)}</Text> characters in {count(t.calls)} calls
643 </Text>
644 {t.agents.map(agent => (
645 <Box key={`agent:${agent.agent}`} flexDirection="column">
646 <Text wrap="truncate-end">
647 <Text color="cyan">{agent.agent}</Text> <Text dimColor>{count(agent.calls)} calls</Text> {count(agent.chars)}
648 </Text>
649 {agent.tools.map(tool => (
650 <Text key={`tool:${agent.agent}:${tool.tool}`} wrap="truncate-end">
651 {' '}
652 {tool.tool} <Text dimColor>{count(tool.calls)} calls</Text> {count(tool.chars)}
653 </Text>
654 ))}
655 </Box>
656 ))}
657 {t.largest.length > 0 ? <Text dimColor>largest results</Text> : null}
658 {t.largest.map((big, index) => (
659 <Text key={`big:${index}`} wrap="truncate-end">
660 {' '}
661 {count(big.chars)} {big.tool} <Text dimColor>{big.agent} {big.call}</Text>
662 </Text>
663 ))}
664 </Box>
665 )
666 })
667
668 on('ui.render', { component: 'Pane', requestId: 'names' }, async ($, e) => {
669 const { Box, Text } = $.ui.resolve(e)
670 const list = (await read($, views)).names ?? []
671 if (list.length === 0) {
672 return (
673 <Box flexDirection="column">
674 <Text dimColor>No agent has called nib yet.</Text>
675 </Box>
676 )
677 }
678 return (
679 <Box flexDirection="column">
680 {list.map(agent => (
681 <Box key={`agent:${agent.agent}`} flexDirection="column">
682 <Text color="cyan">{agent.agent}</Text>
683 {agent.names.length === 0 ? <Text dimColor>{' '}no names</Text> : null}
684 {agent.names.map(name => (
685 <Text key={`name:${agent.agent}:${name.name}`} wrap="truncate-end">
686 {' '}~{name.name} = {name.directory} <Text dimColor>{name.source}, {count(name.uses)} paths</Text>
687 </Text>
688 ))}
689 </Box>
690 ))}
691 </Box>
692 )
693 })
694
695 on('ui.render', { component: 'Pane', requestId: 'seen' }, async ($, e) => {
696 const { Box, Text } = $.ui.resolve(e)
697 const list = (await read($, views)).seen ?? []
698 if (list.length === 0) {
699 return (
700 <Box flexDirection="column">
701 <Text dimColor>No agent has called nib yet.</Text>
702 </Box>
703 )
704 }
705 const ranges = (seen: [number, number][]) => seen.map(([from, to]) => (from === to ? String(from) : `${from}-${to}`)).join(', ')
706 return (
707 <Box flexDirection="column">
708 {list.map(agent => (
709 <Box key={`agent:${agent.agent}`} flexDirection="column">
710 <Text color="cyan">{agent.agent}</Text>
711 {agent.files.length === 0 ? <Text dimColor>{' '}nothing held</Text> : null}
712 {agent.files.map(file => (
713 <Text key={`file:${agent.agent}:${file.path}`} wrap="truncate-end">
714 {' '}
715 {file.path}
716 <Text dimColor>#{file.tag} {count(file.lines)} lines </Text>
717 {file.anywhere ? <Text dimColor>edits may anchor anywhere</Text> : file.seen.length === 0 ? <Text dimColor>none seen</Text> : <Text>seen {ranges(file.seen)}{file.moreRanges ? ` and ${file.moreRanges} more ranges` : ''}</Text>}
718 {file.known ? <Text dimColor> known {file.known}</Text> : null}
719 </Text>
720 ))}
721 {agent.more ? <Text dimColor>{' '}and {agent.more} more files</Text> : null}
722 </Box>
723 ))}
724 </Box>
725 )
726 })
727}
728types/index.d.ts 45 lines1/** A nibash call still running in its request, as nib's nibash_status reports it; times are Unix milliseconds. */
2export type Command = { agent: string; command: string; description?: string; started: number; tail?: string[] }
3
4/** A nibash job, running or ended; exit and ended are set once it ended. */
5export type Job = { id: number; agent: string; command: string; started: number; ended?: number; running: boolean; exit?: number; log: string; tail?: string[] }
6
7/** A decision or summary model nib is asking now, as nibash_status reports it; started is Unix milliseconds. */
8export type ModelCall = { kind: string; model: string; started: number }
9
10/** A tool call of a hosted provider (zemble_search) running, as nibash_status reports it; started is Unix milliseconds. */
11export type ProviderCall = { agent: string; tool: string; started: number }
12
13/** One nibash_status answer, with when it was asked. */
14export type Status = { commands: Command[]; jobs: Job[]; models?: ModelCall[]; summaries?: boolean; calls?: ProviderCall[]; at: number }
15
16/** A path nib moved to the trash that is still there; path is absolute and what a restore takes. */
17export type TrashEntry = { path: string; shown: string; when: number; dir?: boolean }
18
19/** The named directories of each agent, as nib lists them; source is server, defined, automatic, inherited or message. */
20export type NameList = { agent: string; names: { name: string; directory: string; source: string; uses: number }[] }[]
21
22/** The lines nib counts each agent as having seen of its files: inclusive 1-based ranges; anywhere says nib has no record, so edits may anchor on any line. */
23export type SeenList = {
24 agent: string
25 files: { path: string; tag: string; lines: number; anywhere?: boolean; seen: [number, number][]; moreRanges?: number; known?: number }[]
26 more?: number
27}[]
28
29/** What nib's tools returned in this session, in characters of text: by agent and tool, and the largest results. */
30export type Tokens = {
31 chars: number
32 calls: number
33 agents: { agent: string; calls: number; chars: number; tools: { tool: string; calls: number; chars: number }[] }[]
34 largest: { agent: string; tool: string; chars: number; call: string }[]
35}
36
37/** The lists the view panes draw, each as nib last gave it; note is nib's answer to the last restore. */
38export type Views = { trash?: TrashEntry[]; tokens?: Tokens; names?: NameList; seen?: SeenList; note?: string }
39
40declare module 'claude-code' {
41 interface PluginState {
42 nibash: { status: Status | null; server: string; views: Views }
43 }
44}
45