Read htui's state from Claude Code and hand work to 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.
htui opens instantly and stays readable when the server is out of reach.agy) from inside the app, and replay any recorded conversation later.It runs on Windows 10+, Linux and macOS.
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.
You need:
rust-toolchain.toml, so rustup downloads it on your first build.pkg-config, which the keyring integration builds against (sudo apt install libdbus-1-dev pkg-config on Debian and Ubuntu).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
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.
| Option | What it does | |
|---|---|---|
--demo | Start with the built-in sample data in memory instead of connecting to a database. | |
--offline | Open from the local copy only and never try to connect. | |
--set-dsn | Read a Postgres connection string from standard input, save it in the OS keyring and exit. | |
--clear-dsn | Remove the saved connection string from the OS keyring and exit. | |
--dsn-stdin | Read 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-keys | Ignore keys.toml for this run and use the default keys. | |
--print-keys | Print 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-items | Update 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. | |
--decisions | With --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). | |
--json | With --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, --version | Print usage or the version and exit. |
htui needs Postgres 16 or newer.
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.
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:
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.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.
htui opens from its local copy first and connects in the background. The top bar shows where it stands:
| Top bar | Meaning |
|---|---|
memory | Running with --demo. No server and no local copy. |
connecting | The first connection attempt has not answered yet. |
online | Connected. The local copy is kept up to date in the background. |
offline · 3m | No 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.
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.
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.
| Tab | What it is for |
|---|---|
| 1 Backlog | The workspace's items, grouped by project, with details on the right. This is also where you drive runs. |
| 2 Skills | Your skill library and prompt templates: browse versions, compare them, edit, import, and attach skills to projects and repositories. |
| 3 Requirements | Each 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 Settings | Agents, workspaces and projects, item kinds, prompt settings, the database connection, the search server, this machine's profile and agent personas. |
| 5 Chat | A 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.
| Key | Action |
|---|---|
q | Quit |
Tab / Shift+Tab | Next / previous tab |
1 … 5 | Go to a tab |
w | Switch workspace |
? | Show or hide the key help |
Esc | Close 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.
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.
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).
| Key | Action |
|---|---|
j / k, g / G | Move, jump to the first / last row |
Enter on a project | Fold or unfold the project |
h / l | Previous / next detail sub-tab |
J / K, PageUp / PageDown | Scroll 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 / F | Filter the list / clear the filter |
N / e | New item / edit the item |
m | Open the Graph sub-tab |
Q / P | Queue 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.
| Key | Action |
|---|---|
R | Start a run on the item |
a / x | Approve / reject the step (rejecting asks for a note) |
r | Retry the step |
s | Pick this step as the winner of a fan-out |
A | Accept the step's output |
o | Read the step's output document |
p | Continue the step as a chat |
i | Send a running step's agent a follow-up; it is sent when the current turn ends |
Enter | Replay the step's conversation in the Chat tab |
c | Cancel the run |
T | Retry the run's cleanup |
u | Unblock the item |
C | Close out the item: pick how it ends with ← / →, press y, then type the item's key to confirm |
v | Switch 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.
| Key | Action |
|---|---|
J / K | Move between citations |
r | Re-confirm a suspect citation against the requirement as it reads now |
c | Cite another requirement of the item's project (Enter picks it, then a addresses or v reserves) |
u | Remove 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.
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 ✕.
| Key | Action |
|---|---|
j / k, g / G | Move, jump to the first / last row |
Enter on a project or area | Fold or unfold it |
J / K, PageUp / PageDown | Scroll the detail pane |
/ | Filter the list as you type (Enter keeps the filter, Esc clears it) |
a | Add an area to the project |
n | Write a new requirement in the selected area |
e | Amend the requirement |
W | Withdraw the requirement (asks you to type its key to confirm) |
r | Read 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.
| Key | Action |
|---|---|
i / Enter | Write a message (Enter sends, Esc stops writing) |
a | Pick another agent (before the first message) |
1 … 9 | Answer the agent's permission request |
Esc Esc | End the conversation |
t | Show or hide the agent's thinking |
j / k, g / G | Scroll |
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.
| Section | What you do there |
|---|---|
| Agents | See which agents work on this machine (r checks again), install an agent's adapter (i), and log in (a). |
| Hierarchy | Create 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). |
| Kinds | Item kinds, their phases and step graphs; a phase's persona field binds a persona to it. |
| Prompt | Prompt settings such as the token budget. |
| Connection | The database connection string (e edit, c clear) and rebuilding the local copy (R). |
| Qdrant | The search server's address and API key (e edit, c clear). |
| Boxes | This 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). |
| Personas | Agent 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.
htui comes with three agents:
| Agent | What it is |
|---|---|
claude | Claude Code over ACP, the Agent Client Protocol. The recommended choice. |
claude-cli | Claude Code through its command-line JSON stream. A fallback for when the ACP adapter can't be installed. |
agy | Google 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.
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
hooks/register.tsx 344 lines1// 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}
344hooks/version.ts 2 lines1export const VERSION = '0.0.0-source'
2types/index.d.ts 26 lines1/** 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