SLOPSHOPPER

nibash

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…

newpanebandguardcommandtoast
v0.1.0no licenseupdated 2026-10-2311ways/nib/mods/nibash
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nibash
│ ┃ nibash ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing runs in nibash, and no job ended in │ ┃ the last 10 minutes. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /nibash │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · nibash
Nothing runs in nibash, and no job ended in the last 10 minutes.
Pane · trash
Nothing this session moved to the trash is still there.
Pane · tokens
No tool call of nib has returned anything in this session yet.
Pane · names
No agent has called nib yet.
Pane · seen
No agent has called nib yet.
README

nib

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

Requirements

  • Linux, macOS, or Windows (but only Linux has been tested heavily)
  • rg (ripgrep) on PATH
  • bwrap for sandbox mode (Linux only)
  • Go to build; Bun for the black-box tests and the benchmark

Install

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.

Setup

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:

OptionEffect
--provider NAME=PROGRAMhost a provider's tools (Providers)
--decider KINDfixed, keywords or openrouter:MODEL,...
--summary LISTsummarizers for long output
--opencode PROGRAMthe OpenCode 2 binary opencode: summarizers run

By hand

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__.*.

Tools

ToolInputPurpose
readpaths[], optional maxLineLength, plain, collapse, info, fullFiles, ranges, declarations, directories, images, revisions and SQLite tables.
greppattern, optional paths[], glob, ignoreCase, literal, before, after, mode, noIgnore, maxLineLength, skip, replaceripgrep search; match rows are edit anchors; replace previews a replacement.
findoptional paths[], glob, minSize, newer, sort, noIgnore, skipLists files with line counts, sizes and ages.
editinput (a patch, or REAPPLY TOKEN)Edits, creates, copies, moves and deletes files.
writepath, content, optional tagCreates a file, or overwrites one given its current tag.
nibashcommand, optional description, expect, timeout, backgroundRuns a command in nibash.
nibash_statusoptional tail, view, restore, summariesLists running commands and jobs; used by the nibash mod.
skimpaths[], expectReads 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.

read

[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:

SelectorReads
path:A, path:A-B, path:A+N, path:A-, path:-N, path:A-B,C-Dlines
path:name, path:Type.member, path:a,bdeclarations, with their comments and annotations
path#TAGwhat changed since that version (nib keeps 4 per file)
path@REVthe 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:

  • Files over 500 lines or 20 KiB (code over 200 lines or 8 KiB) return an outline: real rows for top-level lines and first-level members. Flat data (logs, CSV, JSON) returns its first 100 lines instead.
  • Lockfiles, minified or bundled files and files in node_modules/ or dist/ over 4 KiB are skipped with their size.
  • A SQLite database lists its tables, columns and row counts.

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.
  • Runs of 3 or more lines the agent already saw unchanged print as N~TEXT rows or one [lines 40-120 unchanged since you saw them] row; full: true prints them whole.

skim

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.

grep

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.

  • The search is rg --hidden without .git/, with ripgrep's --engine auto, so look-around runs on PCRE2.
  • Git's ignore rules apply inside repositories; a nested repository its parent ignores is still searched by its own rules. 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.
  • A path may carry a line selector (app.log:1100-1250) to search only those lines.
  • mode: "files" lists matching paths; mode: "count" counts per file.
  • Files over 8 MiB or still being written print 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.

find

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.

edit

[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
HunkEffect
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 NDelete 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: OLDReplace text OLD, which occurs once on line N, with the + rows; a lone + deletes it.
~TEXTUnder a PUT or CUT: check that line N starts with TEXT; a second ~ checks line M.
REMDelete the file (to the trash).
MV DESTMove the file after the section's edits.
CP DESTCopy 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: PATHCreate a file from the + rows.
[PATH]A header without a tag; allows only PUT >$: and IMPORT.
  • Line numbers refer to the tagged output and are never shifted by earlier hunks. Hunks must not overlap.
  • A hunk may only anchor on lines the agent has seen whole (in a read, grep, nibash rows or an edit response) or confirmed with a ~ check. Otherwise the edit is refused, showing those lines, with a token that applies it unchanged.
  • A stale tag is rebased when every anchored line maps to an unchanged current line; otherwise the edit is refused with the current content.
  • A 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.
  • Block ends (N*) come from tree-sitter in the languages read parses, from indentation elsewhere.
  • Diff-style - rows that match the replaced lines are accepted.
  • Every section is checked before anything is written. Each file is written atomically, keeping its BOM, line endings, final newline, mode and owner. Hardlinked targets are refused.
  • The response gives the new tag and one unchanged row on each side of every change; rows the agent sent print as [lines 40-46 as you sent them].
  • The edit applies but warns when a range looks one line off, brackets stop balancing, the file stops parsing, a Java class is used without an import (giving the 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).

write

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.

nibash

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.

  • git. Where its output reaches the model, 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.
  • find and ls print paths grouped by folder.
  • git-each ARGS runs 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.
  • Deletes go to the trash. 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.
  • A failed cd (or pushd, popd) ends the call unless the command line handles it (if, &&, ||), as under set -e.
  • Jobs. 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.
  • Changed files are listed with their new tags after the command; small changes to files the agent saw print as rows.
  • Failures that the exit status hides (stderr sent to /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.

Long output

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.

DeciderKeeps
keywords (default)the first 30 and last 100 rows, plus rows naming errors or failures with 3 rows around each
fixedthe first 100 and last 200 rows
openrouterkeyword 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.

Command rules

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.

Named directories

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"}}.
  • JVM source roots (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.
  • Directories an agent keeps seeing, which nib names itself and announces in a note. Names used in two sessions are kept in ~/.local/state/nib/names/ for later ones. NIB_AUTO_NAMES=0 turns this off.

Agents and seen lines

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.

Sandboxing

  • 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.

Providers

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.

The nibash mod

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.

Environment variables

VariableDefaultMeaning
NIB_DIRECTORYworking directorythe project directory
NIB_SHELLon0 turns nibash off
NIB_SANDBOXunset1 confines every tool to the roots
NIB_SANDBOX_NETWORKunset1 gives sandboxed programs the network
NIB_SANDBOX_BINDunsetdirectories (: separated) bound read-only in the sandbox
NIB_PROVIDERS_SANDBOXunset1 allows providers under NIB_SANDBOX=1
NIB_CONFIRM_OUTSIDE_ROOTSunset1 asks before writes outside the roots
NIB_AUTO_NAMESon0 turns off automatic named directories
NIB_CHANNELunset1 when Claude Code was started with nib's channel, so job ends wake the agent
NIB_DECIDERkeywordsfixed, keywords or openrouter
NIB_DECIDER_KEY$OPENROUTER_API_KEYOpenRouter key; else ~/.config/nib/openrouter-key, then OpenCode's login
NIB_DECIDER_MODELtypesafe/jev-1.14decision models, comma separated, in order of preference
NIB_DECIDER_URLhttps://openrouter.ai/api/alpha/decisionsdecision endpoint
NIB_DECIDER_TIMEOUT30000milliseconds per decision
NIB_DECIDER_SUMMARYunsetsummarizers, comma separated, in order of preference
NIB_DECIDER_SUMMARY_URLhttps://openrouter.ai/api/v1/chat/completionschat endpoint
NIB_DECIDER_SUMMARY_TIMEOUT30000milliseconds per summary
NIB_DECIDER_OPENCODEopencodethe OpenCode 2 binary
NIB_DECIDER_CACHEunsetdirectory to cache decisions and summaries on disk
NIB_DECIDER_SANDBOXunset1 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.

Platforms

  • On macOS the trash is ~/.Trash; on Windows nib uses a freedesktop-layout trash in ~/.local/share/Trash.
  • On Windows, programs run in a job object; kill supports HUP, INT, QUIT, KILL and TERM. nibash runs programs from PATH (e.g. Git for Windows), and process substitution is not available.
  • Sandbox mode needs bwrap, so it works on Linux only.

Development

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.

Source 2 files
hooks/register.tsx 728 lines
1import { 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}
728
types/index.d.ts 45 lines
1/** 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