SLOPSHOPPER

htui

Read htui's state from Claude Code and hand work to htui

newpanebandcommandprocesstimer
v0.0.0-sourceno licenseupdated 2026-10-13mluigi/htui/crates/htui/claude-plugin/htui
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · htui
│ ┃ htui ✕ › fix the failing auth test and add an audit log call │ ┃ htui │ ┃ htui printed nothing (exit 0) ⏺ 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 │ │ › /htui │ │ htui printed nothing (exit 0) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
htui printed nothing (exit 0)
Pane · htui
htui htui printed nothing (exit 0)
README

htui

htui is a keyboard-driven terminal app for running a backlog of work through AI coding agents. It keeps your work items, their documents, the runs agents performed and full chat transcripts in one Postgres database, and lets you browse and drive all of it from the terminal, across several projects, repositories and machines.

  • One place for everything. Items, documents, notes, runs, requirements, skills and prompt templates live in Postgres. Credentials live in your OS keyring, never in a config file.
  • Works offline. A local copy of what you browse is kept on each machine, so htui opens instantly and stays readable when the server is out of reach.
  • Talks to real agents. Chat with Claude Code or Google's Antigravity (agy) from inside the app, and replay any recorded conversation later.
  • Drives runs step by step. Start a run on an item, approve or reject each step, retry, cancel and close out, all from the Backlog.
  • Never blocks. Network, database and agent work happen in the background, so the UI stays responsive.

It runs on Windows 10+, Linux and macOS.

Contents

Quick start

Try it without a database, using built-in sample data:

cargo run -p htui -- --demo

The demo opens on the Graphics workspace with a few projects, items, runs, notes and documents to look around. Press ? at any time to see the keys for the screen you are on, and q to quit.

Building from source

You need:

  • Rust 1.98.1. The exact version is pinned in rust-toolchain.toml, so rustup downloads it on your first build.
  • On Linux: the D-Bus development files and pkg-config, which the keyring integration builds against (sudo apt install libdbus-1-dev pkg-config on Debian and Ubuntu).
  • Network access on first use of the concepts index. Nothing is downloaded while building. The embedding model (about 134 MB) is downloaded into your user cache directory the first time you index or search, and checked against a pinned hash. An existing download from an earlier htui is reused. On a machine without network access, place onnx/model.onnx (saved as model.onnx) and tokenizer.json from Xenova/bge-small-en-v1.5 at commit ea104dacec62c0de699686887e3f920caeb4f3e3 in <user cache>/htui/model/bge-small-en-v1.5-ea104dac/ (~/.cache on Linux, ~/Library/Caches on macOS, %LOCALAPPDATA% on Windows): files whose sha256 matches the pins in crates/htui-store/src/model.rs are used and nothing is downloaded.
  • git on your PATH, for running work in repositories.

Then build:

cargo build --release

The binary is target/release/htui (target/release/htui.exe on Windows).

To install it onto your PATH (~/.cargo/bin) instead, use cargo install. htui is not published to crates.io, so install from git or from a checkout:

cargo install --git https://git.mluigi.it/htui.git htui --locked
cargo install --path crates/htui --locked

Toolchain

workspace.package.rust-version in Cargo.toml and clippy.toml both say 1.98, matching the pinned toolchain on purpose: with an exact pin, the only minimum version that is both true and checkable is the one everybody actually builds with. Keep all three in step when you move the pin.

Command-line options

OptionWhat it does
--demoStart with the built-in sample data in memory instead of connecting to a database.
--offlineOpen from the local copy only and never try to connect.
--set-dsnRead a Postgres connection string from standard input, save it in the OS keyring and exit.
--clear-dsnRemove the saved connection string from the OS keyring and exit.
--dsn-stdinRead the connection string for this session from standard input; it is never stored. For a TUI over ssh -t on a machine with no keyring.
--keys <PATH>Read key bindings from this file instead of keys.toml. See Changing keys.
--default-keysIgnore keys.toml for this run and use the default keys.
--print-keysPrint every key binding in force as a commented keys.toml and exit. With an invalid file, print its errors and exit with status 2.
--index-itemsUpdate the search index from the database, then exit. See Search.
--search-items <QUERY>Search items, their documents and requirements, print the results and exit. Exits 3 when the database or the search index cannot be reached, 2 on a refusal (for example no index address or connection string saved) and 1 on any other failure.
--project <SLUG>With --index-items or --search-items: only this project. status and waiting take their own --project, after the subcommand.
--decisionsWith --search-items: only decisions, meaning items closed as done, concluded or rejected, and their documents.
--limit <N>With --search-items: how many results to print, from 1 to 1000 (default 10).
--jsonWith --search-items: print one JSON document instead of lines. See Scripting htui.
--cwd <PATH>With --search-items: only the project whose repository, registered on this machine, contains PATH. Cannot be combined with --project.
--log <PATH>Append logs to a file. Also read from HTUI_LOG; HTUI_LOG_FILTER changes the level (default info). Logs never go to the terminal, since the terminal is the app (htui worker, which has none, logs to standard error without it).
worker [--pool-size N] [--dsn-stdin] [--log PATH]Run this machine's runs with no terminal, so they outlive the TUI: htui worker. Needs the box's executor set to worker in Settings › Boxes; no other option may be given beside it, and --log goes after worker. See docs/htui-worker.md.
provision <DESTINATION> [--dsn-stdin] [--replace-credential] [--log PATH]Install this build as the htui-worker service on another Linux machine over ssh, with the DSN encrypted there by systemd-creds, and set that box's executor to worker. See docs/htui-worker.md.
`status [--json] [--project SLUG \--cwd PATH] [--log PATH]`Print the top bar's facts and exit: the workspace, this machine, and how many runs are working and how many things wait on you. Reads only. See Scripting htui.
`waiting [--json] [--project SLUG \--cwd PATH] [--log PATH]`Print what waits on you, as the waiting-on-you list shows it, with how long each has waited, and exit. Reads only. See Scripting htui.
--help, --versionPrint usage or the version and exit.

Connecting to your database

htui needs Postgres 16 or newer.

Run a local server

The repository includes a compose.yaml that starts Postgres 16 on port 5439, so it does not clash with a Postgres you may already have on 5432:

docker compose up -d
echo postgres://postgres:REDACTED@localhost:5439/htui | htui --set-dsn

The same file also starts Qdrant, which is only needed for Search.

Save your connection string

The connection string (DSN) is stored in your OS keyring: Windows Credential Manager, the macOS Keychain, or the Secret Service on Linux. There is no environment variable or config file for it, so it never ends up in your shell history or process list.

You can save it in either of two ways:

  • From inside the app: open Settings › Connection and press e. The input is masked. Press Enter to save, and htui connects straight away without a restart. A machine with no saved connection opens on this screen automatically.
  • From the command line: run htui --set-dsn, paste the DSN and press Enter. You can also pipe it in, as in the example above. htui --clear-dsn removes it again.

TLS follows whatever your DSN asks for, for example ?sslmode=require.

Online and offline

htui opens from its local copy first and connects in the background. The top bar shows where it stands:

Top barMeaning
memoryRunning with --demo. No server and no local copy.
connectingThe first connection attempt has not answered yet.
onlineConnected. The local copy is kept up to date in the background.
offline · 3mNo connection for three minutes. You can browse what was copied locally, but nothing can be changed and no chat or run can start.

While offline, htui retries every 30 seconds and goes back online on its own. Use --offline to look at the local copy on purpose.

Database upgrades

When a new version of htui needs to upgrade your database, it asks first and never upgrades on its own:

3 schema migrations are pending. Apply them now?

y apply · n / Esc stay offline

Press y to upgrade and go online, or n / Esc to leave the database untouched and keep browsing the local copy. If the database is newer than your copy of htui, htui stays offline and says why on the status line; update htui to connect.

Using htui

Everything happens in a workspace. Press w to switch workspaces. If the database has none yet, create one in Settings › Hierarchy with N.

htui has five tabs. Switch with Tab / Shift+Tab or the number keys 1 to 5.

TabWhat it is for
1 BacklogThe workspace's items, grouped by project, with details on the right. This is also where you drive runs.
2 SkillsYour skill library and prompt templates: browse versions, compare them, edit, import, and attach skills to projects and repositories.
3 RequirementsEach project's requirements, grouped by area: what each one says, which items cite it and how it changed over time. The project's owner adds, amends and withdraws them here.
4 SettingsAgents, workspaces and projects, item kinds, prompt settings, the database connection, the search server, this machine's profile and agent personas.
5 ChatA live conversation with an agent, and replays of recorded ones.

Press ? on any screen for its full list of keys. The ones below are the essentials.

Everywhere

KeyAction
qQuit
Tab / Shift+TabNext / previous tab
1 … 5Go to a tab
wSwitch workspace
?Show or hide the key help
EscClose the open pop-up

Most lists use j / k (or the arrow keys) to move, and h / l (or [ / ]) to switch between sub-tabs or sections.

Changing keys

htui reads your key bindings from keys.toml in its configuration directory (see Where htui keeps its files); --keys <PATH> reads another file instead. The file is optional. List only what you change: a list replaces that action's keys, and [] unbinds it. htui --print-keys prints every action with its keys as a commented keys.toml: start from it, and run it again to check your file.

version = 1

[global]
quit = ["ctrl-y"]

[overlay]
close = ["esc", "f2"]

Within one table, your keys win over the defaults you leave alone: if a key you list is the default of another action in the same table, that action gives it up, and is unbound if it had no other key. htui starts anyway and says so on stderr, with the file's path and line, as in [global] quit = "ctrl-q" takes "ctrl-q" from global.queue, which is now unbound; htui --print-keys marks that action's line (unbound by quit). List the action with another key to keep it.

htui refuses to start on an invalid file and names every bad line, as in keys.toml:5: [global] quit = "ctrl-c": ctrl-c always quits and cannot be bound; htui --default-keys starts with the default keys anyway. Two actions you list with the same key are an error. A default gives way to a key from another table in one case only: a [global] key may take the in-pane editor's abort key (ctrl-x) from [editor] abort, which is then unbound, with the same notice. Otherwise a key shared between a pop-up's keys and [global] help is an error in either direction (such as esc for [global] help, or f1 for [overlay] close). [editor] focus must keep a key, and no other action in any table may use it. ctrl-c always quits and cannot be bound. The [editor] rules are in docs/htui-editor.md.

A table named after a Settings section, a Skills view, [requirements], a Backlog sub-tab ([backlog.runs], [backlog.docs], …), [chat] or a pop-up changes a shared action there alone: [settings.boxes] reload = ["f5"] reloads Boxes on F5, and every other section keeps r. [backlog] holds the Backlog's own keys (filter, new item, queue, …), [queue] the queue overlay's (Ctrl+q: move, reorder, pause, dequeue, open) and [picker] the directory picker's (Settings › Hierarchy, b); [backlog.runs] filter = ["/"] moves the filter key in the Runs sub-tab alone. htui --print-keys prints every table that has keys. To see what a table may change, write a name htui does not know in it: the error lists every action that table has and every shared action it may override. A line copied from --print-keys into such a table, like [settings.agents] next_field, replaces that row, so it stops following the shared [form], [common] or [confirm] line; delete it to let it follow again.

A global key must not be a key that Settings, Skills, Requirements, the Backlog, Chat or a pop-up already uses: [global] quit = ["x"] is refused, because x cancels in Settings › Agents and rejects a step in the Backlog's Runs; so is [global] quit = ["f"], because f opens the Backlog filter. A shared table is checked the same way on every screen that uses it: [list] down = ["G"] is refused, because G jumps to the bottom in Chat, and so is [pane] next_subtab = ["tab"], because Tab moves to the next tab. Some Settings forms also move between fields with Down / Up. A [form] next_field line changes them too, and they keep Down; write [settings.agents] next_field = [...] to replace it in that form alone. While a text field takes typing, the keys the field uses itself (letters, arrows, Enter, Esc) stay the field's. In the same way, keys a screen keeps for itself (a choice's value keys, such as Space and h / l on the Backlog filter's rows, or the digits that answer a permission request, the Requirements priority's m / l / Space / ← / →, or the attachment form's activation Space) cannot be given to an action there: [backlog.filter_form] clear = ["space"] is refused. Tab still leaves a Skills editor with the draft kept. In the Backlog's item form and the Notes and Docs editors, [form] save and [form] external_editor replace Ctrl+s and Ctrl+e.

The row above the status line names the keys of what you are in, and follows your keys.toml; ? lists all of them. Every action with its default keys is in docs/htui-keys.md, the output of htui --default-keys --print-keys.

Backlog

Select an item on the left to see it on the right. The detail pane has seven sub-tabs: Body, Runs, Graph (linked items), Docs, Notes, Prompt and Reqs (the requirements the item cites).

KeyAction
j / k, g / GMove, jump to the first / last row
Enter on a projectFold or unfold the project
h / lPrevious / next detail sub-tab
J / K, PageUp / PageDownScroll the detail pane; in Runs, Graph, Docs and Reqs J / K move the pane's cursor, and in Graph PageUp / PageDown move it a page
f / FFilter the list / clear the filter
N / eNew item / edit the item
mOpen the Graph sub-tab
Q / PQueue or dequeue the item / pause or resume this box's queue
?Every key of the pane you are in

The row above the status line names the keys of the focused sub-tab, then the list's when they fit. Ctrl+q opens this box's queue: j / k move, J / K reorder, P pauses or resumes, Q dequeues and Enter opens the item.

Runs. The Runs sub-tab lists an item's runs and their steps. Move between steps with J / K. If an action isn't allowed right now, the status line says why and nothing happens.

KeyAction
RStart a run on the item
a / xApprove / reject the step (rejecting asks for a note)
rRetry the step
sPick this step as the winner of a fan-out
AAccept the step's output
oRead the step's output document
pContinue the step as a chat
iSend a running step's agent a follow-up; it is sent when the current turn ends
EnterReplay the step's conversation in the Chat tab
cCancel the run
TRetry the run's cleanup
uUnblock the item
CClose out the item: pick how it ends with ← / →, press y, then type the item's key to confirm
vSwitch between the list and the flow view
+ / -, =In the flow view: zoom in / out, fit the whole run

The flow view draws the run under the cursor as a graph. Fan-out candidates sit side by side, the judge goes below them, and a retry follows the attempt it replaces. It uses the same cursor as the list, so J / K and every key above work in both views.

When you close out an item, the picker offers only the endings that fit its status. A done item starts on done and can end as done, concluded, rejected, withdrawn, superseded or duplicate. An open, blocked or failed item starts on withdrawn and can also end as rejected, superseded or duplicate.

Runs need a connection to the database.

Graph. The Graph sub-tab draws the items the selected one links to. J / K and PageUp / PageDown move the cursor, + / - show more or fewer links deep, and Enter re-roots the graph on the item under the cursor.

Docs. J / K move between the item's documents, a writes a new document and v a new version of the one under the cursor; Ctrl+s saves.

Notes. a adds a note to the item; Ctrl+s saves it.

Prompt. The Prompt sub-tab shows exactly what an agent would be sent for this item: the assembled text, its token budget, what was trimmed to fit, and which files were picked as context. n / p switch between the project's templates. Looking at a prompt changes nothing and starts nothing.

Reqs. The Reqs sub-tab lists the requirements the item cites, and how: it addresses a requirement, reserves it for later, or is the item that amends or withdraws it. A citation is marked ! suspect when the requirement has changed since the item cited it.

KeyAction
J / KMove between citations
rRe-confirm a suspect citation against the requirement as it reads now
cCite another requirement of the item's project (Enter picks it, then a addresses or v reserves)
uRemove the citation (asks you to confirm with y)

A citation made by amending or withdrawing a requirement is part of that decision and stays. Citing needs a connection to the database.

Requirements

The left pane lists the workspace's projects, their areas and each area's requirements, with their priority (must or later). Select a requirement to see its full text and rationale, which items cite it (a ! suspect marker means the item cited an older version) and its revision history. Withdrawn requirements stay in the list, dimmed and marked ✕.

KeyAction
j / k, g / GMove, jump to the first / last row
Enter on a project or areaFold or unfold it
J / K, PageUp / PageDownScroll the detail pane
/Filter the list as you type (Enter keeps the filter, Esc clears it)
aAdd an area to the project
nWrite a new requirement in the selected area
eAmend the requirement
WWithdraw the requirement (asks you to type its key to confirm)
rRead the requirements again

In the forms, Tab moves between fields and Ctrl+s saves. An amend or a withdraw names the item that decided it, and that item then cites the requirement.

A project's requirements belong to whoever first added an area or a requirement to it. Anyone can read them, but only the owner can add, amend or withdraw; for everyone else, and while offline, the write keys are greyed out and the status line says why. If someone else changed a requirement while you were amending it, your text is kept and Ctrl+s saves over the new version.

Chat

KeyAction
i / EnterWrite a message (Enter sends, Esc stops writing)
aPick another agent (before the first message)
1 … 9Answer the agent's permission request
Esc EscEnd the conversation
tShow or hide the agent's thinking
j / k, g / GScroll

The header shows the agent, the model and the project. If the agent can't do something, such as ask for permission or propose edits, a banner says so before you start typing.

Chats need a connection to the database. When you quit, htui stops every running agent and waits for it to exit.

Replays open read-only over the Chat tab. Esc closes the replay and brings back your live conversation exactly as it was.

Settings

SectionWhat you do there
AgentsSee which agents work on this machine (r checks again), install an agent's adapter (i), and log in (a).
HierarchyCreate and edit workspaces (N), projects and repositories (n), and tell htui where each repository lives on this machine (b picks a directory, or i detects them).
KindsItem kinds, their phases and step graphs; a phase's persona field binds a persona to it.
PromptPrompt settings such as the token budget.
ConnectionThe database connection string (e edit, c clear) and rebuilding the local copy (R).
QdrantThe search server's address and API key (e edit, c clear).
BoxesThis machine's profile: tags (t), quirks (e), who runs its runs, the TUI or htui worker (w), and a fresh check of its tools (p).
PersonasAgent personas: create (n), edit the fields (e), the body (b) and the permission rules (r), delete (d), and import .md persona files (I). See docs/personas.md.

Deleting in Hierarchy shows what would be removed and asks you to type a confirmation, because it cannot be undone.

Setting up agents

htui comes with three agents:

AgentWhat it is
claudeClaude Code over ACP, the Agent Client Protocol. The recommended choice.
claude-cliClaude Code through its command-line JSON stream. A fallback for when the ACP adapter can't be installed.
agyGoogle Antigravity, through Google's ACP server.

Check Settings › Agents to see which ones work on this machine. n adds an agent and e edits the selected one.

Installing an adapter

Some agents need a separate adapter program. For agy, that is Google's agy_acp_server, which the agy command-line tool does not install. In Settings › Agents, select the agent and press i. Before anything is downloaded, htui shows what it will install: the version, where it comes from, its size, whe

Source 3 files
hooks/register.tsx 344 lines
1// htui's mod: a band above the prompt when something waits on you, a /htui
2// command, and a pane. It reads htui's state only by running `htui ... --json`
3// (docs/htui-cli.md) and never writes anything of htui's.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register, Timer } from 'claude-code'
6import type { HtuiRow, HtuiWaiting } from '../types'
7import { VERSION } from './version'
8
9const waiting = atom({ plugin: 'htui', key: 'waiting' } as const, null as HtuiWaiting | null)
10const skew = atom({ plugin: 'htui', key: 'skew' } as const, null as string | null)
11const active = atom({ plugin: 'htui', key: 'active' } as const, false as boolean)
12
13/** What one run of `htui` came to: its document and exit code, or the line that says why there is none. */
14type Ran = { ok: true; doc: any; exitCode: number } | { ok: false; line: string }
15
16/** The line for an answer the mod cannot read. */
17const unreadable = (exitCode: number): string => `htui: unreadable answer (exit ${exitCode})`
18
19/** A count as htui prints one: a whole number, not negative (V3). */
20const isCount = (v: unknown): v is number => Number.isSafeInteger(v) && (v as number) >= 0
21
22/** A JSON object: not null, not a list. */
23const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
24
25/** The pane's id, and the requestId its `ui.render` carries. */
26const PANE = 'htui'
27
28/** `42s`, `5m`, `3h`, `2d`: the largest whole unit, as htui's own text output. */
29const age = (s: number): string =>
30  s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86400)}d`
31
32/**
33 * `s` as a string with its control characters dropped, as htui's own text output drops them
34 * (`state_cmd.rs`'s `clean`): stored text must not drive the terminal, nor break a one-line row.
35 */
36const clean = (s: unknown): string => String(s ?? '').replace(/\p{Cc}/gu, '')
37
38/** A row's reason as the overlay names it: `judge_failed` reads `judge failed`. */
39const label = (reason: string): string => reason.replace(/_/g, ' ')
40
41/** Cuts `s` to `columns` characters, a trailing `…` marking the cut. */
42const fit = (s: string, columns: number): string => {
43  const chars = Array.from(s)
44  return chars.length <= columns ? s : chars.slice(0, Math.max(0, columns - 1)).join('') + '…'
45}
46
47/**
48 * One pane row: reason, item, step and age in columns of 13, 10, 16 and 4, then the text, cut to
49 * the pane's width. A value as wide as its column or wider still ends in one space (V4).
50 */
51const rowLine = (r: HtuiRow, columns: number): string =>
52  fit(
53    `${label(r.reason).padEnd(12)} ${r.item_key.padEnd(9)} ${(r.step ?? '-').padEnd(15)} ${age(r.age_seconds).padStart(4)}  ${r.text}`,
54    columns,
55  )
56
57/** Runs `htui` and reads its JSON answer. Never throws: every failure is a line. */
58async function runHtui($: EngineInterface, argv: readonly string[], timeoutMs: number): Promise<Ran> {
59  const started = await $.clock.now()
60  let r
61  try {
62    r = await $.process.run(argv, { timeoutMs })
63  } catch {
64    const elapsed = (await $.clock.now()) - started
65    return { ok: false, line: elapsed >= timeoutMs ? 'htui: timed out' : 'htui: not found on PATH' }
66  }
67  if (r.stdout.trim() === '') return { ok: false, line: `htui printed nothing (exit ${r.exitCode})` }
68  let doc: any
69  try {
70    doc = JSON.parse(r.stdout)
71  } catch {
72    doc = undefined
73  }
74  if (!isObject(doc)) return { ok: false, line: unreadable(r.exitCode) }
75  if (typeof doc.version === 'number' && doc.version > 1) {
76    return {
77      ok: false,
78      line: `htui: answer version ${doc.version} is newer than this plugin: run htui claude emit --force, then /reload-plugins`,
79    }
80  }
81  if (typeof doc.error?.message === 'string') return { ok: false, line: `htui: ${clean(doc.error.message)}` }
82  if (r.exitCode !== 0) return { ok: false, line: `htui: exit ${r.exitCode}` }
83  return { ok: true, doc, exitCode: r.exitCode }
84}
85
86/**
87 * The rows of a `waiting` document, reduced to the fields the mod reads and stripped of control
88 * characters; an entry that is not an object (a list included) is left out.
89 */
90const rowsOf = (rows: readonly unknown[]): HtuiRow[] =>
91  rows
92    .filter(isObject)
93    .map((r: any) => ({
94      reason: clean(r.reason),
95      item_key: clean(r.item_key),
96      step: typeof r.step === 'string' ? clean(r.step) : null,
97      text: clean(r.text),
98      age_seconds: Math.max(0, Math.floor(Number(r.age_seconds) || 0)),
99    }))
100
101/**
102 * A `waiting` run's rows and workspace name; a document whose `rows` is not a list is unreadable,
103 * so that no count is shown that htui did not give (V3).
104 */
105function waitingOf(ran: Ran): { ok: true; rows: HtuiRow[]; workspace: string | null } | { ok: false; line: string } {
106  if (!ran.ok) return ran
107  if (!Array.isArray(ran.doc.rows)) return { ok: false, line: unreadable(ran.exitCode) }
108  return { ok: true, rows: rowsOf(ran.doc.rows), workspace: ran.doc.workspace ? clean(ran.doc.workspace.name) : null }
109}
110
111/** `/htui waiting`'s text, one line per row. */
112const waitingText = (rows: readonly HtuiRow[]): string =>
113  rows.length === 0
114    ? 'Nothing waits on you.'
115    : rows.map(r => `${r.item_key}  ${label(r.reason)}  ${r.step ?? '-'}  ${age(r.age_seconds)}  ${r.text}`).join('\n')
116
117/** `/htui status`'s text: the top bar's line, a note when permissions are unknown, then the skew line. */
118async function statusText($: EngineInterface): Promise<string> {
119  const ran = await runHtui($, ['htui', 'status', '--json'], 10000)
120  const lines: string[] = []
121  if (ran.ok && !(isCount(ran.doc.working) && isCount(ran.doc.waiting))) {
122    lines.push(unreadable(ran.exitCode))
123  } else if (ran.ok) {
124    const d = ran.doc
125    lines.push(
126      `${d.workspace ? clean(d.workspace.name) : 'no workspace'} · ${d.box ? clean(d.box.hostname) : 'no box'} · ${clean(d.store)} · ${d.working} working · ${d.waiting} waiting`,
127    )
128    if (d.permissions_known === false) lines.push('permission requests could not be listed; the count may be low')
129  } else {
130    lines.push(ran.line)
131  }
132  const s = await read($, skew)
133  if (s !== null) lines.push(s)
134  return lines.join('\n')
135}
136
137/** `/htui search <query>`'s text, one line per hit. The query rides inside one argument (A-6). */
138async function searchText($: EngineInterface, query: string): Promise<string> {
139  const ran = await runHtui($, ['htui', `--search-items=${query}`, '--json'], 60000)
140  if (!ran.ok) return ran.line
141  if (ran.doc.index === 'empty') return 'No concepts index yet: run htui --index-items.'
142  const hits: any[] = Array.isArray(ran.doc.hits) ? ran.doc.hits.filter(isObject) : []
143  if (hits.length === 0) return 'No hits.'
144  return hits
145    .map(
146      h =>
147        `${clean(h.key)}  ${clean(h.place)}${h.document_kind ? ' ' + clean(h.document_kind) : ''}${h.resolution ? ' (' + clean(h.resolution) + ')' : ''}  ${clean(h.snippet)}`,
148    )
149    .join('\n')
150}
151
152/** `auto`, `command` or `off` (D12), as `register` read it from the options. */
153let panel: 'auto' | 'command' | 'off' = 'auto'
154/** The poll period: `poll_seconds` clamped to [5, 3600]; 0 or a non-number reads as 30 (A-12). */
155let periodMs = 30000
156/** `poll_seconds` as a period in seconds: 0 or a non-number is 30; else clamped to [5, 3600]. */
157const pollSeconds = (v: unknown): number =>
158  typeof v !== 'number' || Number.isNaN(v) || v === 0 ? 30 : Math.min(3600, Math.max(5, v))
159/** The poll timer while one runs; a module reload drops it. */
160let timer: Timer | null = null
161/** Set while a poll runs, so a slow poll is never overlapped by the next tick. */
162let polling = false
163
164/** One `htui waiting` poll into `waiting`; a failure replaces the rows, never keeps them. */
165async function poll($: EngineInterface): Promise<void> {
166  if (polling) return
167  polling = true
168  try {
169    const got = waitingOf(await runHtui($, ['htui', 'waiting', '--json'], 10000))
170    const at = await $.clock.now()
171    const next: HtuiWaiting = got.ok
172      ? { rows: got.rows, workspace: got.workspace, at, error: null }
173      : { rows: [], workspace: null, at, error: got.line }
174    await update($, waiting, () => next)
175  } catch {
176    // The module unloaded mid-poll (its `$` is gone): nothing is left to show.
177  } finally {
178    polling = false
179  }
180}
181
182/** Whether the engine lists the `htui` pane as open (A-4), or null when it cannot say. */
183async function paneOpen($: EngineInterface): Promise<boolean | null> {
184  try {
185    return (await $.ui.panes()).some(p => p.id === PANE)
186  } catch {
187    return null
188  }
189}
190
191/** One period: under `command`, stops the timer once the pane is closed; otherwise polls. */
192async function tick($: EngineInterface): Promise<void> {
193  if (panel === 'command') {
194    const isOpen = await paneOpen($)
195    if (isOpen === null) return
196    if (!isOpen) {
197      timer?.cancel()
198      timer = null
199      return
200    }
201  }
202  await poll($)
203}
204
205/** V2: under `command`, a pane that stayed open across a reload polls again, as /htui would. */
206async function resume($: EngineInterface): Promise<void> {
207  if ((await paneOpen($)) !== true) return
208  void poll($)
209  startTimer($)
210}
211
212/** Starts the poll timer unless one runs. */
213function startTimer($: EngineInterface): void {
214  if (timer !== null) return
215  timer = $.clock.every(periodMs, () => void tick($))
216}
217
218/**
219 * D14: names a binary whose version is not the plugin's. Every run writes `skew`,
220 * because `$.state` outlives a reload: after `emit --force` and `/reload-plugins`
221 * the line an earlier check set must go. A failure writes null; the poll reports it.
222 */
223async function checkVersion($: EngineInterface): Promise<void> {
224  let line: string | null = null
225  try {
226    const r = await $.process.run(['htui', '--version'], { timeoutMs: 10000 })
227    // The version is htui-supplied text like any other: stripped of control characters (F7c).
228    const bin = r.exitCode === 0 ? clean(/^htui (\S+)/m.exec(r.stdout)?.[1]) : ''
229    if (bin !== '' && bin !== VERSION) {
230      line = `htui ${bin} ≠ plugin ${VERSION}: run htui claude emit --force, then /reload-plugins`
231    }
232  } catch {
233    // Not on PATH or timed out: the poll reports that.
234  }
235  try {
236    await update($, skew, () => line)
237  } catch {
238    // The module unloaded mid-check (its `$` is gone): nothing is left to show.
239  }
240}
241
242export const register: Register = (on, options) => {
243  panel = options.panel === 'command' || options.panel === 'off' ? options.panel : 'auto'
244  periodMs = pollSeconds(options.poll_seconds) * 1000
245  timer = null
246  polling = false
247
248  on('session.start', async ($, e, next) => {
249    if ((await $.env.get('HTUI_SESSION')) !== undefined) return next(e)
250    await $.command.register({
251      name: 'htui',
252      description: 'htui: what waits on you, status, item search',
253      argumentHint: '[status|waiting|search <query>]',
254    })
255    await update($, active, () => true)
256    void checkVersion($)
257    if (panel === 'auto') {
258      void poll($)
259      startTimer($)
260    } else if (panel === 'command') {
261      void resume($)
262    }
263    return next(e)
264  })
265
266  on('command.run', { command: 'htui' }, async ($, e) => {
267    // D15: a session htui drives never registers /htui; should the command reach the hook anyway,
268    // it runs nothing. Still no `next` (A-2): the hook answers its own command.
269    if (!(await read($, active))) return { text: 'htui: stands down in sessions htui drives' }
270    const args = e.args.trim()
271    const space = args.search(/\s/)
272    const verb = space === -1 ? args : args.slice(0, space)
273    const rest = space === -1 ? '' : args.slice(space).trim()
274
275    if (verb === '' && panel !== 'off') {
276      const o = await $.ui.open({ id: PANE, title: 'htui' })
277      void poll($)
278      startTimer($)
279      return o.isPlaced ? {} : { text: `htui: the pane waits: ${o.reason}` }
280    }
281    if (verb === '' || verb === 'waiting') {
282      const got = waitingOf(await runHtui($, ['htui', 'waiting', '--json'], 10000))
283      return { text: got.ok ? waitingText(got.rows) : got.line }
284    }
285    if (verb === 'status') return { text: await statusText($) }
286    if (verb === 'search') {
287      return { text: rest === '' ? 'usage: /htui search <query>' : await searchText($, rest) }
288    }
289    return { text: 'usage: /htui [status|waiting|search <query>]' }
290  })
291
292  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
293    if (panel !== 'auto' || e.props.hasSurvey || !(await read($, active))) return next(e)
294    const w = await read($, waiting)
295    const s = await read($, skew)
296    const n = w?.rows.length ?? 0
297    const error = w?.error ?? null
298    if (n === 0 && error === null && s === null) return next(e)
299    const { Box, Text } = $.ui.resolve(e)
300    return (
301      <Box key="htui-band" flexDirection="column">
302        {n > 0 && <Text wrap="truncate">{`htui: ${n} waiting on you`}</Text>}
303        {s !== null && <Text wrap="truncate">{s}</Text>}
304        {error !== null && (
305          <Text dimColor wrap="truncate">
306            {error}
307          </Text>
308        )}
309      </Box>
310    )
311  })
312
313  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
314    const w = await read($, waiting)
315    const s = await read($, skew)
316    const columns = e.props.bodyColumns
317    const rows = w?.rows ?? []
318    // V1: before the first answer, or after a failed poll, no count is known: the header says only
319    // `htui`, and the line beneath says why.
320    const header = w === null || w.error !== null ? 'htui' : `${w.workspace ?? 'no workspace'} · ${rows.length} waiting`
321    const { Box, Text } = $.ui.resolve(e)
322    return (
323      <Box key="htui-pane" flexDirection="column">
324        <Text bold wrap="truncate">
325          {header}
326        </Text>
327        {s !== null && <Text wrap="truncate">{s}</Text>}
328        {w?.error != null && (
329          <Text dimColor wrap="truncate">
330            {w.error}
331          </Text>
332        )}
333        {rows.map((r, i) => (
334          <Box key={`row-${i}`}>
335            <Text wrap="truncate">{rowLine(r, columns)}</Text>
336          </Box>
337        ))}
338        {w === null && <Text dimColor>htui: no answer yet</Text>}
339        {w !== null && w.error === null && rows.length === 0 && <Text dimColor>Nothing waits on you.</Text>}
340      </Box>
341    )
342  })
343}
344
hooks/version.ts 2 lines
1export const VERSION = '0.0.0-source'
2
types/index.d.ts 26 lines
1/** One row of `htui waiting --json` as the mod reads it (docs/htui-cli.md, "waiting"). Other fields are ignored. */
2export type HtuiRow = { reason: string; item_key: string; step: string | null; text: string; age_seconds: number }
3
4/** The last poll: its rows, or the visible line for a poll that failed (then `rows` is empty). */
5export type HtuiWaiting = {
6  rows: readonly HtuiRow[]
7  /** `workspace.name` as the top bar shows it, or null when the store has no workspace. */
8  workspace: string | null
9  /** `$.clock.now()` when the poll answered. */
10  at: number
11  error: string | null
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    htui: {
17      /** null until the first poll answers. */
18      waiting: HtuiWaiting | null
19      /** D14's skew line, or null. */
20      skew: string | null
21      /** True once session.start ran and did not stand down (D15); the render hooks read it. */
22      active: boolean
23    }
24  }
25}
26