Use ordinary keys and plain-language Beehive manager copy

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-11 18:47:56 -04:00
parent cb1b341ccb
commit 4bdb5f09a9
8 changed files with 378 additions and 140 deletions

View File

@@ -21,14 +21,16 @@ Arguments still route to the supported existing Node CLI; no renderer/domain for
owner verified before OS-only persistence. Sign-out retains the saved key and does
not Stop hosts/agents. Missing, locked, denied and mismatched keys are not reset.
- Actual private host availability and per-host agent inventory, selectable JSON
detail with actual run versus selected-next and explicit stale/UNKNOWN wording.
detail with Current run (host report) versus Configuration for next start and
explicit unknown status.
This is **not an independent agent catalog**. No fabricated inventory rows.
- Select an existing named next configuration; exact host/agent/revision confirmation.
Explicit Start and Stop through the existing durable management intent client.
Start requires a fresh assigned stopped report without actual run. Unresolved
operations fence further lifecycle submissions; inspect/reconcile remains available.
- Selectable operation records include operation ID, exact target, CAS revision,
receipt/publication and unknown state. Reconcile does not retry blocked work.
receipt/publication and unknown state. Check operation results reconnects and can
resend pending work, but does not retry operations blocked by relay policy.
- Offline multiline private instruction draft creation, atomically persisted using
the existing draft API. Publishing/applying remains a distinct existing CLI action.
@@ -38,7 +40,8 @@ Arguments still route to the supported existing Node CLI; no renderer/domain for
Existing backend Restart can consume a different selected binding. Rather than
relying on a disabled button or changing established service semantics in this
bounded prototype, this manager sends no Restart at all. Explicit Stop, inspect
accepted stopped state, then Start is available. Existing advanced CLI retains
a recent host report that confirms the agent is stopped, then Start is available.
Existing advanced CLI retains
its prior explicit Restart behavior; it does not promise same-runtime Restart.
- Foreground host service launch remains `beehive host --owner-present`; Quit this
manager first. No daemon, process ownership transfer or auto service management.
@@ -48,7 +51,7 @@ Arguments still route to the supported existing Node CLI; no renderer/domain for
- Existing draft editing/resume, profile publication/application, public metadata
editing/publication and policy retry remain supported through `beehive drafts`
and `beehive tui discover <relay> ~/.beehive/owner`. New forms explicitly say
“Not available in this build”; these are not simulated failures or fake success.
“unavailable”; these are not simulated failures or fake success.
- No owner-key generation/reveal UI, independent relay catalog, zero-agent reusable
runtime catalog, identity-only registration, Move UI or remote provider login.
- For lists/actions longer than their visible pane, use keyboard arrows/Enter.
@@ -118,18 +121,56 @@ Node 24.15.0 and Bun 1.4.2 executables at `beehive/runtime/node` and
`beehive/runtime/bun`. Link `~/.local/bin/beehive` to the exported package's
`bin/beehive`, not to a worktree. See the immutable delivery recipe for the exact pin.
## Local visual-polish candidate (not installed)
## Ordinary-key and plain-language candidate (not installed)
The subsequent presentation candidate replaces default JSON walls with Node-built
public summaries and F3 technical evidence; uses ≥88×24 split / narrow Enter-to-detail;
puts contextual guarded commands in F2; retains permanent keys with F4 scrollable
outcomes; and uses compact validated inputs plus a named configuration chooser.
The installed `6390e594` exports are deliberately unchanged pending independent
implementation-vs-design review.
The presentation candidate replaces default JSON walls with Node-built plain-language
summaries and `i` technical evidence; uses ≥88×24 split / narrow Enter-to-detail;
puts contextual guarded commands in the `a` actions list; retains a persistent
footer with `?` help and `o` scrollable latest status message; and uses compact validated
inputs plus a named configuration chooser. Every affordance is reachable
without a function key: `?` help, `a` actions, `i` inspect, `o` Status, `q` or
Ctrl-Q quit; Tab/Shift-Tab, arrows, Enter and Esc keep their existing routing,
including pending-Esc priority. Letter shortcuts never fire inside public,
secret or multiline forms — there they type as text — and never clear values,
bypass confirmations or double-invoke operations. F1–F4 remain undocumented
compatibility aliases; footer, help and instructions never lead with function
keys. Quit and sign out are never Stop, and Restart remains refused at the
controller.
Evidence and the corrected external design are under
Earlier polish evidence and the corrected external design are under
`/Users/loganj/.buzz/artifacts/beehive-opentui-polish-6390e594/implementation/`.
Read `IMPLEMENTATION.md` there for the eight-point self-review, scoped deviations,
actual PTY comparisons versus offscreen fixtures, exact runtime commands and limits.
This candidate does not claim exhaustive design conformance, real relay inventory,
light-terminal visual proof, or safe scrolled-action mouse activation.
Ordinary-key correction evidence is under
`/Users/loganj/.buzz/artifacts/beehive-opentui-keys-cb1b341/`. Neither claims
exhaustive design conformance, real relay inventory, light-terminal visual
proof, or safe scrolled-action mouse activation.
### Combined candidate validation
Copy follows the bounded packet at
`/Users/loganj/.buzz/artifacts/beehive-plain-language-0d962712/COPY_CHANGES.md`.
Status is the latest message, not history. Display translations do not rename
protocol fields or change operation classification. Configuration names, model
names and workspace values remain literal even if they match an operation result.
Inspect retains raw evidence. Unknown status never means stopped.
Integration evidence: `/Users/loganj/.buzz/artifacts/beehive-integration-abe68e43/`.
Direct Node 24.15.0 typecheck passed; Bun 1.4.2 renderer tests passed 3/3.
One initial renderer failure was a stale “Terminal too small” text assertion;
only that assertion changed, and the failure log is retained. The one full Node
package run completed in 28.910s: **150 pass, 2 fail, 19 skip** (171 total).
All five manager tests passed, including the new raw-evidence/literal-value and
unknown-state presentation test. Full-suite failures remain unmodified:
`broker.test.ts:31` expected a successful verification value, and
`credential-helper.test.ts:33` received its 1000ms deadline error where cancellation
was expected. Neither path imports this presentation change; the broker cause is
not established. No rerun-to-green, timing changes or full-suite green claim.
Actual 100×30 worktree PTY smoke used the committed installed-manager loader,
synthetic credentials and disconnected transport in a fresh fixture directory.
Ordinary-key Help, Actions, configuration form/confirmation, Inspect, Status and
Quit passed; exit 0 and alternate-screen restoration were verified. No production
credentials, relay, provider, host lifecycle or installed-package change was used.
Narrow renderer checks cover the 40-column footer, input and pane routing; this is
not an exhaustive screenshot matrix for every prompt or launch error. Publication
and installed desktop/laptop smoke belong to the next continuation.

View File

@@ -15,11 +15,104 @@ export type ManagerRequest = { id: number; action: string; values?: Record<strin
export type ManagerItem = { id: string; label: string; detail: string; evidence?: string; disabled?: Record<string, string> };
export type ManagerSnapshot = { local: ManagerItem[]; agents: (ManagerItem & { revision: number; configurations: string[] })[]; routing?: { owner: string; relay: string }; owner?: string; status: string };
const short = (s: string) => s.length > 22 ? `${s.slice(0,8)}…${s.slice(-6)}` : s;
const describe = (v: unknown): string => {
/** Final plain display text thrown by this boundary; never rewrapped or retranslated. */
class PlainStatus extends Error {}
const plain = (text: string) => new PlainStatus(text);
/** Shared backend validation/protocol sentinels are translated here, at the manager
* display boundary only. Legacy CLI text, tests and result sentinels stay exact;
* unknown diagnostics are retained after a plain failure context, never replaced. */
const backendMessages: Record<string, string> = {
'Unsupported Beehive controller configuration': 'Saved Beehive configuration uses an unsupported version.',
'Invalid retained management relay URL': 'The saved relay URL is invalid.',
'Controller already configured; use Settings for deliberate changes': 'Owner and relay are already configured. They cannot be changed in this view.',
'Management relay requires wss:// (ws://127.0.0.1 for fixtures)': 'Enter a relay URL that starts with ws:// or wss://.',
'Owner PUBLIC npub or 64-character hex key required; never enter a private key': 'Enter the owner’s public key as an npub or 64 hex characters. Do not enter a private key.',
'Legacy identity retained; explicit consent required for migration': 'The saved host identity uses an older format. It has not been changed. Migration requires explicit permission.',
'Invalid host credential reference': 'The saved host key reference is invalid.',
'Explicit stopped migrate-slots required': 'This installation needs migration. Stop its agents before you use migrate-slots.',
'Installation requires 1..32 slots': 'The installation must contain 1 to 32 agent records.',
'Invalid slot identity': 'An agent record has an invalid identity.',
'Invalid slot binding': 'An agent record refers to an invalid local setup.',
'Public slot binding mismatch': 'The saved agent record does not match its host, owner or agent identity.',
'Shared harness inventory must not copy key material': 'Shared local setups must not contain identity keys.',
'Binding cannot carry identity': 'A local setup must not contain host or agent identity fields.',
'Binding cannot change conversation authority': 'A local setup cannot change permission to use the conversation.',
'Invalid retired binding definition': 'A retired local setup does not match its saved definition.',
'Public credential installation required; no automatic plaintext migration': 'This installation must use secure key references. Plain-text keys will not be migrated automatically.',
'Invalid agent credential reference': 'The saved agent key reference is invalid.',
'Draft directory must be owner-only': 'Only the local user may access the draft folder.',
'Draft storage exceeds limit': 'The draft file exceeds the size limit.',
'Invalid draft storage': 'The saved draft file is invalid.',
'Invalid draft identity': 'A saved draft has an invalid or duplicate ID.',
'Draft changed; resume again': 'The draft changed. Open it again before you edit.',
'Draft storage full; discard unused drafts': 'Draft storage is full. Discard unused drafts through beehive drafts.',
'Invalid profile name/parent': 'The instructions name or previous revision is invalid. Do not use default as the name.',
'Invalid nonsecret instructions': 'Enter instructions of no more than 2048 UTF-8 bytes. Do not use control characters.',
'Profile revision conflict': 'The instructions do not match their revision.',
'Management journal directory must be owner-only': 'Only the local user may access the operation records folder.',
'Management journal full': 'Operation storage is full.',
'Management journal full; no operation submitted': 'Operation storage is full. No operation was submitted.',
'Oversized intent': 'A saved operation request exceeds the size limit.',
'Oversized receipt': 'A saved host result exceeds the size limit.',
'Oversized blocked intent': 'A saved blocked request exceeds the size limit.',
'Wrong journal scope': 'Saved operation records do not match this owner and relay.',
'Invalid intent': 'A saved operation request is invalid.',
'Unmatched journal receipt': 'A saved host result does not match its operation.',
'Invalid blocked intent': 'A saved blocked request is invalid.',
'Unmatched durable receipt': 'A saved host result does not match its operation.',
'Operation ID already prepared': 'This operation ID is already saved.',
'UI closed': 'Beehive is closed.',
'Relay disconnected; result unknown': 'The relay disconnected. The operation result is unknown.',
};
const plainBackendMessage = (message: string) => backendMessages[message] ?? `Could not complete the operation: ${message}`;
/** Raw management values keep their exact protocol/evidence bytes; only the default
* summary gets plain labels. Unknown nested fields stay technical, never discarded. */
const plainValues: Record<string, string> = {
'immutable publication observed; no agent application': 'Private instructions published. Not applied to an agent.',
'host terminal receipt': 'Host result received.',
'relay policy failure; automatic retry disabled; reconcile, then retry after policy repair': 'Relay policy blocked the operation. Automatic retry is disabled. Check operation results. Correct the policy problem before you retry through the CLI.',
'relay observed; not host admission': 'Relay publication confirmed. Host acceptance not confirmed.',
'unconfirmed': 'Not confirmed.',
'accepted': 'Accepted by host.',
'saved; running configuration unchanged': 'Configuration saved. Current run unchanged.',
'interrupted-reconcile-locally': 'Operation interrupted. Result unknown. Inspect and repair the saved state on the host before you try again.',
};
const plainStates: Record<string, string> = { pending: 'Pending', completed: 'Completed', failed: 'Failed', unknown: 'Unknown' };
const plainTypes: Record<string, string> = { start: 'Start', stop: 'Stop', restart: 'Restart', move: 'Move', metadata: 'Publish profile', profile: 'Publish instructions' };
const plainOperationType = (request: Message) => request.type === 'save' && request.body.configurationAction === 'select' ? 'Choose configuration' : plainTypes[request.type] ?? request.type;
const operationText = (value: unknown) => typeof value === 'string' ? plainValues[value] ?? value : describe(value);
/** Named launch choice: the model/workspace/instruction selection of one agent. */
const selectionLabels: Record<string, string> = {
'model': 'Model', 'workspace': 'Workspace', 'profile': 'Instructions revision',
'harnessSetup.id': 'Local setup', 'harnessSetup.fingerprint': 'Local setup fingerprint',
'configuration.name': 'Configuration name', 'configuration.revision': 'Configuration revision',
'behavior.name': 'Instructions name', 'behavior.instructions': 'Private instructions',
'behavior.revision': 'Instructions revision', 'behavior.parent': 'Previous instructions revision',
};
const runLabels: Record<string, string> = { ...selectionLabels, ...Object.fromEntries(Object.entries(selectionLabels).map(([key, value]) => [`selection.${key}`, value])) };
/** Host availability pairing: host infrastructure, not agent configuration. */
const availabilityLabels: Record<string, string> = {
'configuration': 'Host configuration', 'configuration.host': 'Host', 'configuration.owner': 'Owner',
'configuration.relay': 'Relay', 'configuration.label': 'Host name', 'configuration.observedAt': 'Report time', 'observedAt': 'Report time',
};
/** These containers hold only labelled choice fields; their children speak alone. */
const inlineChoice = new Set(['harnessSetup', 'configuration', 'behavior', 'selection', 'selection.harnessSetup', 'selection.configuration', 'selection.behavior']);
const describe = (v: unknown, labels: Record<string, string> = {}, path = ''): string => {
if (v === undefined || v === null) return 'Not reported';
if (typeof v !== 'object') return typeof v === 'string' && /^[0-9a-f]{32,}$/i.test(v) ? short(v) : String(v);
if (typeof v !== 'object') {
if (typeof v !== 'string') return String(v);
if ((path === 'profile' || path.endsWith('.profile')) && v === 'default') return 'Default instructions';
return /^[0-9a-f]{32,}$/i.test(v) ? short(v) : v;
}
if (!Object.keys(v).length) return 'Not reported';
return Object.entries(v).map(([k, value]) => `${k}: ${describe(value)}`).join('\n');
return Object.entries(v).map(([k, value]) => {
const child = path ? `${path}.${k}` : k;
const rendered = describe(value, labels, child);
if (labels !== availabilityLabels && inlineChoice.has(child) && value !== null && !Array.isArray(value) && typeof value === 'object') return rendered;
return `${labels[child] ?? k}: ${rendered}`;
}).join('\n');
};
/** One bounded Node helper per explicit credential operation. Completion waits for exit.
@@ -40,7 +133,7 @@ export function managerCredential(input: object, signal: AbortSignal, helper = n
child.once('message', value => { result = value; });
child.once('close', code => {
clearTimeout(timer); signal.removeEventListener('abort', cancel);
if (code !== 0 || failed || signal.aborted || !result?.ok) reject(Error('Credential operation cancelled, timed out, denied, mismatched or unavailable. Persistence may have completed; inspect before retry. No reset or plaintext fallback.'));
if (code !== 0 || failed || signal.aborted || !result?.ok) reject(plain('Could not complete key access. It may have been cancelled, timed out, denied, or unavailable. The key may not match. Changes may already be saved. Inspect before you try again. No key was reset or saved as plain text.'));
else resolve(result);
});
child.send(input, error => { if (error) cancel(); });
@@ -58,7 +151,7 @@ export class ManagerController {
private active?: AbortController;
private closed = false;
private lastId = 0;
private status = 'Local Host does not require owner sign-in. Quit/sign out never Stop.';
private status = 'Local Host does not require owner sign-in. Quitting or signing out does not stop hosts or agents.';
readonly home: string;
readonly changed: (snapshot: ManagerSnapshot) => void;
private credential: typeof managerCredential;
@@ -78,52 +171,51 @@ export class ManagerController {
Configuration: saved
Relay: ${identity.pairing.relay}
Owner: ${short(identity.pairing.owner)}
Service: not checked by this view
Host service: not checked
Registration is not Start authority.
Host configuration alone does not permit an agent to start.
Next steps
F2: provision from prepared binding/genesis files.
Quit manager before foreground CLI use:
Open Actions to add an agent from prepared files.
Quit before you run:
beehive host --owner-present
Service launch and binding authoring forms are unavailable.` });
if (existsSync(join(this.hostDirectory, 'setup.json'))) for (const slot of installationPublicSlots(this.hostDirectory)) local.push({ id: slot.agent, label: slot.agent, detail: `Local public slot (not current run status)\nAgent ${slot.agent}\nBindings: ${Object.keys(slot.bindings).join(', ')}` });
} else local.push({ id: 'missing', label: 'Configure this computer', detail: 'No local host configuration. Save owner PUBLIC npub and relay; host identity is created in OS credentials. No owner sign-in, agent or service Start.' });
} catch (error) { local.push({ id: 'error', label: 'Retained configuration needs attention', detail: String(error) + '\nPreserved; no automatic reset.' }); }
Host start and local setup forms are unavailable.` });
if (existsSync(join(this.hostDirectory, 'setup.json'))) for (const slot of installationPublicSlots(this.hostDirectory)) local.push({ id: slot.agent, label: slot.agent, detail: `Local agent record. This is not current run status.\nAgent ${slot.agent}\nLocal setups: ${Object.keys(slot.bindings).join(', ')}` });
} else local.push({ id: 'missing', label: 'Configure this computer', detail: 'No local host configuration. Save the owner’s public key and relay URL. Beehive creates a host identity in the secure credential store. No owner sign-in is needed. This does not create an agent or start a host.' });
} catch (error) { local.push({ id: 'error', label: 'Saved configuration needs attention', detail: (error instanceof Error ? backendMessages[error.message] ?? `Could not read saved configuration: ${error.message}` : `Could not read saved configuration: ${String(error)}`) + '\nSaved data has not been reset.' }); }
const agents: ManagerSnapshot['agents'] = [...this.inventory].map(([id, m]) => {
const fresh = this.fresh(m);
const blocked = !fresh ? 'Host stale or unreachable. Refresh before acting.' : this.client?.status().some(o => o.request.host === m.host && o.request.agent === m.agent && !['completed','failed'].includes(o.state)) ? 'Unresolved operation: inspect receipts before acting.' : '';
return { id, label: `Agent ${short(m.agent)} · ${fresh ? m.body.phase : 'UNKNOWN'}`, revision: m.revision, configurations: Object.keys((m.body.configurations ?? {}) as object),
disabled: { 'select-config': blocked, stop: blocked, start: blocked || (m.body.phase !== 'stopped' || m.body.actualRun || m.body.assignedHost !== m.host ? 'Requires fresh assigned stopped report without an actual run.' : '') },
const blocked = !fresh ? 'The host report is old or the host cannot be reached. Wait for a recent report before you act.' : this.client?.status().some(o => o.request.host === m.host && o.request.agent === m.agent && !['completed','failed'].includes(o.state)) ? 'An operation has no confirmed result. Inspect operations before you act.' : '';
return { id, label: `Agent ${short(m.agent)} · ${fresh ? m.body.phase : 'Unknown'}`, revision: m.revision, configurations: Object.keys((m.body.configurations ?? {}) as object),
disabled: { 'select-config': blocked, stop: blocked, start: blocked || (m.body.phase !== 'stopped' || m.body.actualRun || m.body.assignedHost !== m.host ? 'Start requires a recent report from the assigned host. It must report the agent stopped with no current run.' : '') },
evidence: JSON.stringify(m, null, 2), detail: `Agent ${short(m.agent)}
Host ${short(m.host)}
${fresh ? 'Recent host report' : 'UNKNOWN — stale or unreachable; last reported below'}
Reported phase: ${m.body.phase}
Start authority: ${m.body.assignedHost === m.host ? 'Assigned to this host' : 'Not assigned to this host'}
${fresh ? 'Recent host report' : 'Current status unknown. The report is old or the host cannot be reached. Last report:'}
Reported status: ${m.body.phase}
Assigned host: ${m.body.assignedHost === m.host ? 'Assigned to this host' : 'Not assigned to this host'}
Actual run (reported)
${m.body.actualRun ? describe(m.body.actualRun) : 'No actual run in this report'}
Current run (host report)
${m.body.actualRun ? describe(m.body.actualRun, runLabels) : 'No current run in this report'}
Selected for next Start
${describe(m.body.selectedNext)}
Choosing next does not change the actual run.` };
Configuration for next start
${describe(m.body.selectedNext, selectionLabels)}
This choice does not change the current run.` };
});
for (const [id, m] of this.offers) agents.push({ id: `host:${id}`, label: `Host ${short(id)}`, revision: m.revision, configurations: [], evidence: JSON.stringify(m, null, 2), detail: `Host availability report
for (const [id, m] of this.offers) agents.push({ id: `host:${id}`, label: `Host ${short(id)}`, revision: m.revision, configurations: [], evidence: JSON.stringify(m, null, 2), detail: `Host report
Host ${short(id)}
${this.fresh(m) ? 'Recent host report' : 'UNKNOWN — stale or unreachable'}
${describe(m.body)}
Availability is not agent Start authority.` });
for (const operation of this.client?.status() ?? []) agents.push({ id: `operation:${operation.request.id}`, label: `${operation.request.type} · ${operation.state} · ${operation.request.id.slice(0,8)}`, revision: operation.request.revision, configurations: [], evidence: JSON.stringify(operation, null, 2), detail: `Operation: ${operation.request.type}
State: ${operation.state}
${this.fresh(m) ? 'Recent host report' : 'Current status unknown. The report is old or the host cannot be reached.'}
${describe(m.body, availabilityLabels)}
A host report does not grant permission to start an agent.` });
for (const operation of this.client?.status() ?? []) agents.push({ id: `operation:${operation.request.id}`, label: `${plainOperationType(operation.request)} · ${plainStates[operation.state] ?? operation.state} · ${operation.request.id.slice(0,8)}`, revision: operation.request.revision, configurations: [], evidence: JSON.stringify(operation, null, 2), detail: `Operation: ${plainOperationType(operation.request)}
State: ${plainStates[operation.state] ?? operation.state}
Host: ${short(operation.request.host)}
Agent: ${short(operation.request.agent)}
Captured revision: ${operation.request.revision}
Publication: ${describe(operation.publication)}
Receipt/result: ${describe(operation.result)}
Revision when requested: ${operation.request.revision}
Relay status: ${operationText(operation.publication)}
${operation.request.type === 'profile' ? 'Result' : 'Host result'}: ${operationText(operation.result)}
Publication is not acceptance.
Outcome unknown? Check receipts; do not submit again.
A Stop receipt is not a fresh stopped inventory report.` });
Relay publication does not confirm that the host accepted the operation.
If the result is unknown, check operation results before you submit again.
A Stop result is not a recent host report that confirms the agent is stopped.` });
let routing: ReturnType<typeof readControllerConfig>;
try { routing = readControllerConfig(this.ownerDirectory); } catch { /* Invalid retained routing is refused by sign-in; never reset here. */ }
return { local, agents, routing: routing ? { owner: routing.owner, relay: routing.relay } : undefined, owner: this.owner, status: this.status };
@@ -138,25 +230,25 @@ A Stop receipt is not a fresh stopped inventory report.` });
this.lastId = request.id;
const abort = new AbortController(); this.active = abort;
const generation = ++this.generation;
const check = () => { abort.signal.throwIfAborted(); if (this.closed || generation !== this.generation) throw Error('Cancelled'); };
const check = () => { abort.signal.throwIfAborted(); if (this.closed || generation !== this.generation) throw plain('Stopped waiting. Changes may already be saved or submitted. Inspect before you try again.'); };
const v = request.values ?? {};
try {
if (request.action === 'configure') {
const owner = ownerPublicInput(v.owner ?? '');
if (!/^(wss|ws):\/\//.test(v.relay ?? '')) throw Error('Management relay URL required');
if (!/^(wss|ws):\/\//.test(v.relay ?? '')) throw plain('Enter a relay URL that starts with ws:// or wss://.');
await this.credential({ action: 'configure', directory: this.hostDirectory, label: hostname().slice(0,128), owner, relay: v.relay }, abort.signal);
check(); this.status = 'Configuration saved. This action did not start a service or agent.';
} else if (request.action === 'provision') {
await this.credential({ action: 'provision', directory: this.hostDirectory, binding: v.binding, genesis: v.genesis, secret: v.secret }, abort.signal);
check(); this.status = 'Local agent provisioned stopped using existing binding/genesis APIs. No Start or relay publication.';
check(); this.status = 'Local agent added and stopped. Nothing was started or published to the relay.';
} else if (request.action === 'signin') {
const retained = readControllerConfig(this.ownerDirectory);
const owner = retained?.owner ?? ownerPublicInput(v.owner ?? '');
const relay = retained?.relay ?? v.relay ?? '';
if (!/^(wss|ws):\/\//.test(relay)) throw Error('Management relay URL required');
if (!/^(wss|ws):\/\//.test(relay)) throw plain('Enter a relay URL that starts with ws:// or wss://.');
const result = await this.credential({ action: 'signin', owner, ...(v.secret ? { secret: v.secret.toLowerCase() } : {}) }, abort.signal);
delete v.secret; check();
if (result.secret === null) throw Error('No saved owner key. Use Import owner key with the matching 64-hex private key.');
if (result.secret === null) throw plain('No saved owner key. Choose Import matching owner key and sign in. Enter the matching private key with 64 hex characters.');
if (!retained) createControllerConfig(this.ownerDirectory, owner, relay);
this.client?.close(); this.inventory.clear(); this.offers.clear();
this.owner = owner;
@@ -172,28 +264,28 @@ A Stop receipt is not a fresh stopped inventory report.` });
}, () => this.refresh(), { catalog: { version: 1, owner, relay, registrations: [] } });
result.secret = undefined; this.client = client;
abort.signal.addEventListener('abort', () => client.close(), { once: true });
await client.ready; check(); this.status = 'Owner signed in. Inventory scope: privately advertising hosts and their agent slots, not independent catalog.';
await client.ready; check(); this.status = 'Owner signed in. This list shows private host reports and their agents. It is not a separate agent directory.';
} else if (request.action === 'signout') {
this.client?.close(); this.client = undefined; this.owner = undefined; this.inventory.clear(); this.offers.clear();
this.status = 'Signed out. OS key retained. Hosts and agents were NOT stopped.';
this.status = 'Signed out. The owner key is still saved on this computer. Hosts and agents were not stopped.';
} else if (request.action === 'draft') {
await editProfileDraft(profileDrafts(this.ownerDirectory), async prompt => prompt.startsWith('Profile') ? v.name ?? '' : v.instructions ?? '');
this.status = 'Private instruction draft saved locally. Not published/applied. Resume through beehive drafts ~/.beehive/owner or beehive tui.';
} else if (request.action === 'reconcile') { this.client?.reconcile(); this.status = 'Querying receipts; not retrying blocked operations.';
this.status = 'Private instructions draft saved on this computer. Not published or used by an agent. To resume, use beehive drafts ~/.beehive/owner or beehive tui.';
} else if (request.action === 'reconcile') { this.client?.reconcile(); this.status = 'Checking operation results. Operations blocked by relay policy will not be retried.';
} else if (request.action === 'operations') {
this.status = this.client?.status().map(o => `${o.request.id} · ${o.request.host}/${o.request.agent} · ${o.request.type} · ${o.state}: ${o.result ?? o.publication}`).join('\n') || 'No recorded operations in this session.';
this.status = this.client?.status().map(o => `${o.request.id} · ${o.request.host}/${o.request.agent} · ${plainOperationType(o.request)} · ${plainStates[o.state] ?? o.state}: ${operationText(o.result ?? o.publication)}`).join('\n') || 'No saved operations.';
} else if (['start', 'stop', 'restart', 'select-config'].includes(request.action)) {
if (!this.owner || !this.client) throw Error('Owner sign-in required');
if (!this.owner || !this.client) throw plain('Owner sign-in required');
const current = this.inventory.get(request.target ?? '');
if (!current || !this.fresh(current) || current.revision !== request.revision) throw Error('Selection changed or host stale/unreachable. Refresh; no request sent.');
if (request.action === 'restart') throw Error('Restart: Not available in this build. Backend Restart can switch bindings; use explicit Stop, inspect stopped receipt, then Start. No request sent.');
if (request.action === 'start' && (current.body.phase !== 'stopped' || current.body.actualRun || current.body.assignedHost !== current.host)) throw Error('Start requires a fresh assigned stopped report without an actual run.');
if (this.client.status().some(o => o.request.host === current.host && o.request.agent === current.agent && !['completed','failed'].includes(o.state))) throw Error('Unresolved operation exists. Inspect operations and reconcile before another action.');
if (!current || !this.fresh(current) || current.revision !== request.revision) throw plain('The selection changed, or the host report is old or unavailable. Select an agent with a recent report. No request was sent.');
if (request.action === 'restart') throw plain('Restart can change the local setup. Use Stop. Inspect a recent host report that confirms the agent is stopped. Then use Start. No Restart request is sent. No request was sent.');
if (request.action === 'start' && (current.body.phase !== 'stopped' || current.body.actualRun || current.body.assignedHost !== current.host)) throw plain('Start requires a recent report from the assigned host. It must report the agent stopped with no current run.');
if (this.client.status().some(o => o.request.host === current.host && o.request.agent === current.agent && !['completed','failed'].includes(o.state))) throw plain('An operation has no confirmed result. Inspect operations and check operation results before you act again.');
const operation = message(request.action === 'select-config' ? 'save' : request.action as 'start' | 'stop', current.host, current.agent, current.revision, request.action === 'select-config' ? { configurationAction: 'select', name: v.name } : {});
this.client.submit(operation);
this.status = `Operation ${operation.id} · ${current.host}/${current.agent}: durably pending ${request.action}. Publication is not acceptance. Inspect operations; unknown is not stopped.`;
} else if (request.action !== 'refresh') throw Error('Not available in this build');
} catch (error) { if (!this.closed) this.status = error instanceof Error ? error.message : 'Operation failed; inspect before retry.'; }
this.status = `Operation ${operation.id}: ${plainOperationType(operation)} saved and pending.\nHost: ${current.host}\nAgent: ${current.agent}\nRelay publication does not confirm host acceptance. Inspect operations. Unknown does not mean stopped.`;
} else if (request.action !== 'refresh') throw plain('Not available in this build');
} catch (error) { if (!this.closed) this.status = error instanceof PlainStatus ? error.message : abort.signal.aborted && error === abort.signal.reason ? 'Stopped waiting. Changes may already be saved or submitted. Inspect before you try again.' : error instanceof Error ? plainBackendMessage(error.message) : `Could not complete the operation: ${String(error)}`; }
finally { delete v.secret; this.active = undefined; this.refresh(); }
}
}

View File

@@ -5,7 +5,7 @@ import { homedir } from 'node:os';
import { ManagerController } from './manager-controller.ts';
import type { Readable, Writable } from 'node:stream';
if (process.versions.node !== '24.15.0') throw Error('The OpenTUI manager controller requires Node 24.15.0. Use the packaged launcher/runtime.');
if (process.versions.node !== '24.15.0') throw Error('Beehive requires Node 24.15.0. Use the packaged Beehive launcher.');
const bun = process.env.BEEHIVE_BUN ?? fileURLToPath(new URL('../runtime/bun', import.meta.url));
const child = spawn(bun, [fileURLToPath(new URL('./manager-view.ts', import.meta.url))], {
stdio: ['inherit', 'inherit', 'inherit', 'pipe', 'pipe'],
@@ -28,6 +28,6 @@ input.on('line', line => {
});
const refresh = setInterval(() => controller.refresh(), 2000);
child.once('spawn', () => controller.refresh());
child.once('error', () => { console.error('Beehive presentation runtime unavailable; install the pinned app-local Bun runtime.'); process.exitCode = 1; });
child.once('error', () => { console.error('Beehive could not start its terminal interface. The packaged Bun runtime is unavailable.'); process.exitCode = 1; });
child.once('close', code => { closed = true; clearInterval(refresh); controller.close(); input.close(); process.exitCode = code ?? 1; });
for (const signal of ['SIGINT','SIGTERM'] as const) process.on(signal, () => { controller.close(); child.kill(signal); });

View File

@@ -7,7 +7,7 @@ import type { ManagerRequest, ManagerSnapshot } from './manager-controller.ts';
const renderer = await createCliRenderer({ exitOnCtrlC: false });
const screen = new OpenTuiScreen(renderer);
const output = createWriteStream('', { fd: 4 });
let snapshot: ManagerSnapshot = { local: [], agents: [], status: 'Connecting Node controller…' };
let snapshot: ManagerSnapshot = { local: [], agents: [], status: 'Connecting to Beehive…' };
let scope = 0, selected = '', sequence = 0, lastStatus = '';
let pending: { id: number; resolve: () => void } | undefined;
function request(action: string, values?: Record<string,string>, target?: string, revision?: number) {
@@ -15,7 +15,7 @@ function request(action: string, values?: Record<string,string>, target?: string
const id = ++sequence;
const completion = new Promise<void>(resolve => { pending = { id, resolve }; });
const message: ManagerRequest = { id, action, values, target, revision };
screen.setPending(true); screen.notice(`Working: ${action}… Esc cancels waiting, not submitted remote work or committed OS writes. Inspect before retry.`);
screen.setPending(true); screen.notice('Working… Esc stops waiting. Remote work and saved changes are not cancelled. Inspect before you try again.');
output.write(JSON.stringify(message) + '\n');
return completion;
}
@@ -25,8 +25,8 @@ async function routing(action: string, secret = false) {
if (!retained) {
let step = 0;
while (step < 2) {
const label = step === 0 ? `${action === 'configure' ? 'Configure this computer' : 'Owner sign-in'} · 1 of 3\nOwner PUBLIC npub/hex (required)` : 'Configure routing · 2 of 3\nManagement relay URL (ws:// or wss://)';
const answer = await screen.input(label, step === 0 ? owner : relay, false, false, value => step === 0 ? /^(?:[0-9a-fA-F]{64}|npub1[023456789acdefghjklmnpqrstuvwxyz]{58})$/.test(value.trim()) ? '' : 'Enter a public 64-hex key or npub.' : /^wss?:\/\//.test(value) ? '' : 'Enter a ws:// or wss:// relay URL.', step === 1 ? value => { relay = value; } : undefined);
const label = step === 0 ? `${action === 'configure' ? 'Configure this computer' : 'Sign in as owner'} · 1 of 3\nOwner public key (npub or 64 hex characters). Do not enter a private key.` : 'Relay · 2 of 3\nRelay URL (ws:// or wss://)';
const answer = await screen.input(label, step === 0 ? owner : relay, false, false, value => step === 0 ? /^(?:[0-9a-fA-F]{64}|npub1[023456789acdefghjklmnpqrstuvwxyz]{58})$/.test(value.trim()) ? '' : 'Enter an npub or a public key with 64 hex characters.' : /^wss?:\/\//.test(value) ? '' : 'Enter a ws:// or wss:// relay URL.', step === 1 ? value => { relay = value; } : undefined);
if (answer === undefined) return;
if (answer === '\0back') { step = 0; continue; }
if (step === 0) owner = answer; else relay = answer;
@@ -35,18 +35,18 @@ async function routing(action: string, secret = false) {
}
const values: Record<string,string> = { owner, relay };
if (secret) {
const key = await screen.input('Matching owner private key, 64 hex. Saved only in this computer’s OS credentials. Never sent to a host.', '', true);
const key = await screen.input('Owner private key (64 hex characters). It must match the owner public key. Saved in this computer’s secure credential store. Never sent to a host.', '', true);
if (key === undefined) return;
values.secret = key;
}
if (!await screen.confirm(action === 'configure' ? `Configure this computer · 3 of 3\nOwner: ${owner}\nRelay: ${relay}\nCreate host identity in OS credentials? No agent or Start.` : `Owner: ${owner}\nRelay: ${relay}\n${retained ? 'Retained routing (read-only). ' : ''}Access owner OS credential? Permission may prompt. Matching import only.`)) { delete values.secret; return; }
if (!await screen.confirm(action === 'configure' ? `Configure this computer · 3 of 3\nOwner: ${owner}\nRelay: ${relay}\nCreate a host identity in this computer’s secure credential store? This does not create an agent or start a host.` : `Owner: ${owner}\nRelay: ${relay}\n${retained ? 'Saved owner and relay cannot change here.\n' : ''}Access the owner key in this computer’s secure credential store? The system may ask for permission. An imported key must match the owner.`)) { delete values.secret; return; }
await request(action, values); delete values.secret;
}
function eligibility(action: string) {
const row = snapshot.agents.find(r => r.id === selected);
if (!snapshot.owner) return 'Owner sign-in required.';
if (!row?.disabled) return 'Select an agent observation.';
return row.disabled[action] || (action === 'select-config' && !row.configurations.length ? 'No existing configurations reported.' : undefined);
if (!row?.disabled) return 'Select an agent.';
return row.disabled[action] || (action === 'select-config' && !row.configurations.length ? 'The host reported no saved configurations.' : undefined);
}
function render() {
screen.setOwner(snapshot.owner?.slice(-12));
@@ -55,44 +55,44 @@ function render() {
if (!selected || (scope === 0 && selected === 'missing' && rows.some(r => r.id === 'host'))) selected = rows[0]?.id ?? '';
const vanished = !!selected && !rows.some(row => row.id === selected);
const actions: ManagerAction[] = scope === 0 ? [
snapshot.local.some(r => r.id === 'host') ? { label: 'Host configuration saved — inspect', run: () => screen.inspect() } : { label: 'Configure this computer', disabled: snapshot.local.some(r => r.id === 'error') ? 'Retained configuration needs attention — preserved, not reset.' : undefined, run: () => routing('configure') },
{ label: 'Provision from prepared binding/genesis files', disabled: snapshot.local.some(r => r.id === 'host') ? undefined : 'Configure this computer first.', run: async () => {
const binding = await screen.input('Absolute existing local binding JSON path (no identity fields)'); if (binding === undefined) return;
const genesis = await screen.input('Absolute owner-authorized agent genesis JSON path'); if (genesis === undefined) return;
const secret = await screen.input('Matching AGENT private key, 64 hex. Stored in host OS credentials, not the owner key.', '', true); if (secret === undefined) return;
if (!await screen.confirm('Provision STOPPED agent into this fresh local installation? Existing or partial installations refuse reset. OS access may prompt.')) return;
snapshot.local.some(r => r.id === 'host') ? { label: 'Inspect host configuration', run: () => screen.inspect() } : { label: 'Configure this computer', disabled: snapshot.local.some(r => r.id === 'error') ? 'Saved configuration needs attention. It has not been reset.' : undefined, run: () => routing('configure') },
{ label: 'Add agent from prepared files', disabled: snapshot.local.some(r => r.id === 'host') ? undefined : 'Configure this computer first.', run: async () => {
const binding = await screen.input('Local setup file (binding JSON). Enter its full path. The file must not contain host or agent identity fields.'); if (binding === undefined) return;
const genesis = await screen.input('Agent authorization file (genesis JSON). Enter its full path. The file must be authorized by the owner.'); if (genesis === undefined) return;
const secret = await screen.input('Agent private key (64 hex characters). It must match the agent. Saved in this host’s secure credential store. Do not enter the owner key.', '', true); if (secret === undefined) return;
if (!await screen.confirm('Add a stopped agent to this new local installation? Existing or incomplete installations cannot be reset here. The system may ask for permission.')) return;
await request('provision', { binding, genesis, secret });
} },
{ label: 'Refresh local inspection', run: () => request('refresh') },
{ label: 'Save private instructions draft', run: async () => {
const name = await screen.input('New private instruction draft · 1 of 2\nProfile name', '', false, false, v => v.trim() ? '' : 'A name is required.'); if (name === undefined) return;
const instructions = await screen.input(`Draft: ${name}\nLocal only — not published or applied. Private instructions (not credentials)`, '', false, true, v => v.trim() ? '' : 'Instructions are required.'); if (instructions === undefined) return;
{ label: 'Refresh local details', run: () => request('refresh') },
{ label: 'New private instructions draft…', run: async () => {
const name = await screen.input('Private instructions · 1 of 2\nDraft name', '', false, false, v => v.trim() ? '' : 'Enter a draft name.'); if (name === undefined) return;
const instructions = await screen.input(`Private instructions · 2 of 2\nDraft: ${name}\nSaved only on this computer. Not published or used by an agent. Do not enter passwords or keys.`, '', false, true, v => v.trim() ? '' : 'Enter instructions.'); if (instructions === undefined) return;
await request('draft', { name, instructions });
} },
{ label: 'Provisioning / service commands', run: () => screen.notice('Forms: Not available in this build. Existing CLI: beehive local-setup ~/.beehive/host; beehive provision-agent; beehive host --owner-present. Quit this manager before foreground CLI use.') },
{ label: 'Host setup commands', run: () => screen.notice('These forms are unavailable. Use the CLI:\nbeehive local-setup ~/.beehive/host\nbeehive provision-agent\nbeehive host --owner-present\nQuit Beehive before you use a foreground CLI command.') },
] : snapshot.owner ? [
{ label: 'Choose next configuration…', disabled: eligibility('select-config'), run: async () => {
{ label: 'Choose configuration…', disabled: eligibility('select-config'), run: async () => {
const row = snapshot.agents.find(r => r.id === selected); if (!row) return;
const name = await screen.choose('Choose existing next configuration', row.configurations); if (name === undefined) return;
if (!await screen.confirm(`Select ${name} for exact host/agent ${row.id} at revision ${row.revision}? Actual run unchanged.`)) return;
const name = await screen.choose('Configuration for next start', row.configurations); if (name === undefined) return;
if (!await screen.confirm(`Use ${name} for the next start?\nHost and agent: ${row.id}\nRevision: ${row.revision}\nThis does not change the current run.`)) return;
await request('select-config', { name }, row.id, row.revision);
} },
...(['start','stop','restart'] as const).map(action => ({ label: action === 'restart' ? 'Restart — not available (binding safety)' : `${action === 'start' ? 'Start' : 'Stop'} selected agent`, disabled: action === 'restart' ? 'Backend Restart may switch bindings. Use Stop, inspect accepted stopped report, then Start. No Restart request is sent.' : eligibility(action), run: async () => {
...(['start','stop','restart'] as const).map(action => ({ label: action === 'restart' ? 'Restart — unavailable' : `${action === 'start' ? 'Start' : 'Stop'} selected agent`, disabled: action === 'restart' ? 'Restart can change the local setup. Use Stop. Inspect a recent host report that confirms the agent is stopped. Then use Start. No Restart request is sent.' : eligibility(action), run: async () => {
const row = snapshot.agents.find(r => r.id === selected); if (!row) return;
if (!await screen.confirm(`${action.toUpperCase()} exact agent/host ${row.id} at revision ${row.revision}?`)) return;
if (!await screen.confirm(`${action === 'start' ? 'Start' : 'Stop'} this agent?\nHost and agent: ${row.id}\nRevision: ${row.revision}`)) return;
await request(action, undefined, row.id, row.revision);
} })),
{ label: 'Inspect operation receipts', run: () => request('operations') },
{ label: 'Reconcile (not retry)', run: () => request('reconcile') },
{ label: 'Sign out — not Stop', run: () => request('signout') },
{ label: 'Public metadata / profile publication', run: () => screen.notice('Publication forms: Not available in this build. Existing CLI: beehive drafts ~/.beehive/owner; beehive tui discover <relay> ~/.beehive/owner. Generated keys and independent agent catalog are not implemented.') },
{ label: 'Inspect operations', run: () => request('operations') },
{ label: 'Check operation results', run: () => request('reconcile') },
{ label: 'Sign out', run: () => request('signout') },
{ label: 'Publish profile or instructions', run: () => screen.notice('Publication forms are unavailable. Use the CLI:\nbeehive drafts ~/.beehive/owner\nbeehive tui discover <relay> ~/.beehive/owner\nThis build cannot generate owner keys or list agents independently of hosts.') },
] : [
{ label: 'Sign in with saved owner key', run: () => routing('signin') },
{ label: 'Import matching owner key and sign in', run: () => routing('signin', true) },
];
if (scope === 1 && !snapshot.owner) actions.push({ label: 'New private instruction draft…', run: async () => { const name = await screen.input('New private instruction draft · 1 of 2\nName', '', false, false, v => v.trim() ? '' : 'Name required.'); if (name === undefined) return; const instructions = await screen.input(`Draft: ${name}\nLocal only — not published or applied`, '', false, true, v => v.trim() ? '' : 'Instructions required.'); if (instructions !== undefined) await request('draft', { name, instructions }); } });
actions.push({ label: 'Quit manager (not Stop)', run: () => screen.close() });
screen.show(vanished ? [{ id: selected, label: 'Selection no longer available', detail: 'Selection no longer available. Choose a current observation; no operation is targeted at a replacement row.' }, ...rows] : scope === 0 ? snapshot.local : snapshot.agents.length ? snapshot.agents : [{ id: 'empty', label: snapshot.owner ? 'No host observations yet' : 'Owner sign-in required', detail: 'Agents management is owner-authorized. Inventory comes from privately advertising hosts, NOT an independent relay agent catalog. Empty/offline observations do not prove agents stopped. Local Host and offline drafts do not require sign-in.' }], actions, selected);
if (scope === 1 && !snapshot.owner) actions.push({ label: 'New private instructions draft…', run: async () => { const name = await screen.input('Private instructions · 1 of 2\nDraft name', '', false, false, v => v.trim() ? '' : 'Enter a draft name.'); if (name === undefined) return; const instructions = await screen.input(`Private instructions · 2 of 2\nDraft: ${name}\nSaved only on this computer. Not published or used by an agent. Do not enter passwords or keys.`, '', false, true, v => v.trim() ? '' : 'Enter instructions.'); if (instructions !== undefined) await request('draft', { name, instructions }); } });
actions.push({ label: 'Quit Beehive', run: () => screen.close() });
screen.show(vanished ? [{ id: selected, label: 'Selection no longer available', detail: 'This item is no longer available. Select a current item. No operation will use a replacement item.' }, ...rows] : scope === 0 ? snapshot.local : snapshot.agents.length ? snapshot.agents : [{ id: 'empty', label: snapshot.owner ? 'No host reports yet' : 'Owner sign-in required', detail: 'Sign in as owner to manage agents. This list uses private host reports, not a separate agent directory. An empty list or an offline host does not mean agents are stopped. Local Host and local drafts do not require sign-in.' }], actions, selected);
if (lastStatus !== snapshot.status) { lastStatus = snapshot.status; screen.notice(snapshot.status); }
}
screen.onScope = value => { scope = value; selected = ''; render(); };
@@ -103,7 +103,7 @@ input.on('line', line => {
const value = JSON.parse(line);
if (value.snapshot) { snapshot = value.snapshot; render(); }
if (value.complete === pending?.id) { const old = pending; pending = undefined; screen.setPending(false); old?.resolve(); }
} catch { screen.notice('Controller response unavailable. Quit and inspect retained state.'); }
} catch { screen.notice('Beehive could not read the response. Quit and inspect saved operation records before you try again.'); }
});
input.on('close', () => screen.close());
screen.onCancel = () => { if (pending) output.write(JSON.stringify({ cancel: pending.id }) + '\n'); };

View File

@@ -17,6 +17,7 @@ export class OpenTuiScreen {
private readonly scroll: ScrollBoxRenderable;
private readonly actions: SelectRenderable;
private readonly status: TextRenderable;
private readonly footer: TextRenderable;
private readonly small: TextRenderable;
private rows: ManagerRow[] = [];
private commands: ManagerAction[] = [];
@@ -33,6 +34,11 @@ export class OpenTuiScreen {
onScope: (index: number) => void = () => {};
onSelect: (id: string) => void = () => {};
/** Persistent ordinary-key legend, separate from status. Never function-key first.
* The compact form keeps every affordance named at the 40-column narrow floor. */
private static readonly hintsWide = '? Help · a Actions · i Inspect · o Status · q Quit';
private static readonly hintsNarrow = '? a Actions i Inspect o Status q Quit';
constructor(readonly renderer: CliRenderer) {
this.root = new BoxRenderable(renderer, { width: '100%', height: '100%', flexDirection: 'column', backgroundColor: bg });
renderer.root.add(this.root);
@@ -47,19 +53,20 @@ export class OpenTuiScreen {
// Pinned OpenTUI does not expose its scrolling offset. Never map a click
// to an unrelated row; keyboard navigation remains available for long lists.
if (this.rows.length <= this.list.height && delta >= 0) this.list.setSelectedIndex(Math.min(this.rows.length - 1, delta));
else this.notice('Scrolled list: use arrows to select the exact row. Mouse selection is not available in this build.');
else this.notice('Use arrows to select an item in this list. Mouse selection is unavailable.');
} });
this.listFrame.add(this.list);
this.scroll = new ScrollBoxRenderable(renderer, { flexGrow: 1, height: '100%', border: true, title: 'Details', scrollY: true, onMouseDown: () => { this.focusIndex = 1; this.scroll.focus(); } });
body.add(this.scroll);
this.detail = new TextRenderable(renderer, { fg, width: '100%', content: 'No items.', selectable: false });
this.scroll.add(this.detail);
this.actions = new SelectRenderable(renderer, { backgroundColor: bg, textColor: fg, focusedBackgroundColor: bg, focusedTextColor: fg, selectedBackgroundColor: fg, selectedTextColor: bg, height: 2, showDescription: false, showSelectionIndicator: true, options: [{name: 'Actions (F2)', description: ''}, {name: 'Inspect (F3)', description: ''}], onMouseDown: event => { this.focusIndex = 2; this.actions.focus(); this.actions.setSelectedIndex(Math.max(0, Math.min(1, event.y - this.actions.y))); this.actions.selectCurrent(); } });
this.actions = new SelectRenderable(renderer, { backgroundColor: bg, textColor: fg, focusedBackgroundColor: bg, focusedTextColor: fg, selectedBackgroundColor: fg, selectedTextColor: bg, height: 2, showDescription: false, showSelectionIndicator: true, options: [{name: 'Actions (a)', description: ''}, {name: 'Inspect (i)', description: ''}], onMouseDown: event => { this.focusIndex = 2; this.actions.focus(); this.actions.setSelectedIndex(Math.max(0, Math.min(1, event.y - this.actions.y))); this.actions.selectCurrent(); } });
this.root.add(this.actions);
this.status = new TextRenderable(renderer, { fg, height: 2, onMouseDown: () => this.outcome() });
this.root.add(this.status);
this.root.add(new TextRenderable(renderer, { fg, height: 1, content: 'F1 Keys F2 Actions F3 Inspect ^Q Quit' }));
this.small = new TextRenderable(renderer, { fg, position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', bg, content: 'Terminal too small. Resize to 40 × 16. Nothing submitted. Ctrl-Q quits.', visible: false });
this.footer = new TextRenderable(renderer, { fg, height: 1, content: OpenTuiScreen.hintsWide });
this.root.add(this.footer);
this.small = new TextRenderable(renderer, { fg, position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', bg, content: 'Resize to at least 40 × 16. Ctrl-Q quits. Existing operations can continue.', visible: false });
renderer.root.add(this.small);
this.list.on('selectionChanged', (index: number) => {
const row = this.rows[index]; this.detail.content = clean(row?.detail ?? 'No items.'); this.scroll.scrollTo(0);
@@ -98,6 +105,7 @@ export class OpenTuiScreen {
this.scroll.visible = !this.narrow || this.drilled;
this.listFrame.width = this.narrow ? '100%' : Math.min(32, Math.max(24, Math.floor(this.renderer.width * .28)));
this.scroll.title = this.narrow ? '[Details] · Esc back' : 'Details';
this.footer.content = this.renderer.width >= 52 ? OpenTuiScreen.hintsWide : OpenTuiScreen.hintsNarrow;
}
private heading() { this.header.content = `BEEHIVE ${this.scope === 0 ? '[Local Host]' : 'Local Host'} | ${this.scope === 1 ? '[Agents]' : 'Agents'}\n${this.scope === 0 ? 'This computer · no owner sign-in needed' : 'Owner · ' + clean(this.owner)}`; }
setOwner(publicSuffix?: string) { this.owner = publicSuffix ? `${publicSuffix} · signed in · key saved here` : 'signed out'; this.heading(); }
@@ -111,21 +119,31 @@ export class OpenTuiScreen {
if (key.name === 'escape' && this.pending) { key.preventDefault(); this.onCancel(); return; }
if (this.modal) { this.modal.key(key); return; }
if (key.name === 'escape' && this.drilled) { key.preventDefault(); this.drilled = false; this.resize(); this.focusIndex = 0; this.list.focus(); }
// Ordinary keys are the primary map. Every editable field is a modal handled
// above, so these never intercept text in public, secret or multiline inputs.
if (!key.ctrl && !key.meta) {
if (key.name === '?') { key.preventDefault(); this.help(); return; }
if (key.name === 'a') { key.preventDefault(); this.drawer(); return; }
if (key.name === 'i') { key.preventDefault(); this.inspect(); return; }
if (key.name === 'o') { key.preventDefault(); this.outcome(); return; }
if (key.name === 'q') { key.preventDefault(); this.close(); return; }
}
// Legacy compatibility aliases only; the advertised map above uses ordinary keys.
if (key.name === 'f2') { key.preventDefault(); this.drawer(); return; }
if (key.name === 'f3') { key.preventDefault(); this.inspect(); return; }
if (key.name === 'f4') { key.preventDefault(); this.outcome(); return; }
if (this.focusIndex === 0 && ['home','end','pageup','pagedown'].includes(key.name)) { key.preventDefault(); this.navigate(this.list, key.name); }
if (key.name === 'left' || key.name === 'right') { key.preventDefault(); this.switchScope(1 - this.scope); }
if (key.name === 'tab') { key.preventDefault(); this.focusIndex = (this.focusIndex + (key.shift ? 2 : 1)) % 3; if (this.narrow && this.focusIndex === (this.drilled ? 0 : 1)) this.focusIndex = (this.focusIndex + (key.shift ? 2 : 1)) % 3; [this.list, this.scroll, this.actions][this.focusIndex]!.focus(); this.focusStyle(); }
if (key.name === 'f1') { key.preventDefault(); this.read('Help', 'F2 Actions · F3 public Inspect · F4 full Outcome · Left/Right: scope · Tab/Shift-Tab: panes · arrows/PgDn/Home/End/wheel: navigate · Enter: open detail/action · Esc: cancel pending first, then dismiss/back · Ctrl-Q: quit, never Stop. Scrolled list/action mouse selection unavailable: use keyboard. No new provider, catalog, publication or Restart capability. Quit before foreground CLI use.'); }
if (key.name === 'f1') { key.preventDefault(); this.help(); }
}
private async run(index: number, captured?: ManagerAction) {
const action = captured ?? this.commands[index];
if (!action || this.pending || this.busy || this.modal || this.small.visible || this.closed) return;
if (action.disabled) { this.notice(action.disabled); return; }
this.busy = true; this.notice('Working… Esc cancels an open form. Quit is not Stop.');
this.busy = true; this.notice('Working… Esc cancels an open form. Quitting does not stop agents.');
try { await action.run(); }
catch { this.notice('Action failed. State may be unchanged or outcome unknown; inspect the operation before retrying.'); }
catch { this.notice('Action failed. The result is unknown. Inspect the operation before you try again.'); }
finally { this.busy = false; }
}
show(rows: ManagerRow[], actions: ManagerAction[], selected?: string) {
@@ -139,7 +157,7 @@ export class OpenTuiScreen {
}
notice(text: string) { if (!this.closed) { this.lastNotice = clean(text); this.status.content = this.lastNotice; } }
unavailable(feature: string) { this.notice(`${feature}: Not available in this build. No request made. Local Host and existing host observations remain separate.`); }
unavailable(feature: string) { this.notice(`${feature} is unavailable in this build. No request was sent. Local Host shows this computer. Agents shows host reports.`); }
choose(title: string, names: string[]): Promise<string | undefined> {
if (this.modal || !names.length) return Promise.resolve(undefined);
@@ -156,8 +174,9 @@ export class OpenTuiScreen {
const n = list.options.length, current = list.getSelectedIndex();
list.setSelectedIndex(Math.max(0, Math.min(n - 1, key === 'home' ? 0 : key === 'end' ? n - 1 : current + (key === 'pageup' ? -1 : 1) * Math.max(1, list.height))));
}
inspect() { const row = this.rows[this.list.getSelectedIndex()]; this.read('Technical details · public evidence', row?.evidence ?? 'Inspect unavailable: no technical evidence for this row.'); }
private outcome() { this.read('Outcome · current attempt', this.lastNotice || 'No outcome yet.'); }
inspect() { const row = this.rows[this.list.getSelectedIndex()]; this.read('Technical details', row?.evidence ?? 'No technical details for this item.'); }
private outcome() { this.read('Status · latest message', this.lastNotice || 'No status message yet.'); }
private help() { this.read('Help', '? Help · a Actions · i Inspect · o Status · q Quit\nLeft/Right: switch Local Host and Agents.\nTab/Shift-Tab: switch panes.\nArrows, PgUp/PgDn, Home/End or wheel: scroll.\nEnter: open details or an action.\nEsc: cancel waiting first, then close or go back.\nq or Ctrl-Q: quit. Hosts and agents keep running.\nUse arrows and Enter in scrolled lists. Mouse selection is unavailable.\nShortcuts do not run while you edit a field.\nQuit before you use a foreground CLI command.\nRestart and publication forms are unavailable.'); }
private read(title: string, content: string) {
if (this.modal || this.closed) return;
const box = new ScrollBoxRenderable(this.renderer, { position: 'absolute', top: 2, left: Math.floor((this.renderer.width - Math.min(72, this.renderer.width - 4)) / 2), width: Math.min(72, this.renderer.width - 4), height: this.renderer.height - 4, border: true, title: title + ' · Esc close', backgroundColor: bg, scrollY: true });
@@ -172,9 +191,9 @@ export class OpenTuiScreen {
this.overlay = box; this.compactOverlay = false; this.renderer.root.add(box);
box.add(new TextRenderable(this.renderer, { fg, content: clean(this.rows[this.list.getSelectedIndex()]?.label ?? 'No selection'), height: 2 }));
const reason = new TextRenderable(this.renderer, { fg, height: 4 });
const list = new SelectRenderable(this.renderer, { backgroundColor: bg, textColor: fg, focusedBackgroundColor: bg, focusedTextColor: fg, selectedBackgroundColor: fg, selectedTextColor: bg, flexGrow: 1, showDescription: false, showSelectionIndicator: true, showScrollIndicator: true, options: commands.map(a => ({ name: clean((a.disabled || this.pending ? '(unavailable) ' : '') + a.label), description: '' })), onMouseScroll: event => { if (event.scroll?.direction === 'up') list.moveUp(); else if (event.scroll?.direction === 'down') list.moveDown(); }, onMouseDown: () => this.notice('Actions: use arrows then Enter. Mouse activation is unavailable; no request sent.') });
const list = new SelectRenderable(this.renderer, { backgroundColor: bg, textColor: fg, focusedBackgroundColor: bg, focusedTextColor: fg, selectedBackgroundColor: fg, selectedTextColor: bg, flexGrow: 1, showDescription: false, showSelectionIndicator: true, showScrollIndicator: true, options: commands.map(a => ({ name: clean((a.disabled || this.pending ? '(unavailable) ' : '') + a.label), description: '' })), onMouseScroll: event => { if (event.scroll?.direction === 'up') list.moveUp(); else if (event.scroll?.direction === 'down') list.moveDown(); }, onMouseDown: () => this.notice('Use arrows, then Enter. Mouse selection is unavailable. No request was sent.') });
box.add(list); box.add(reason); list.focus();
const explain = () => { reason.content = this.pending ? 'Working… Esc cancels waiting. Submitted remote work or OS writes may already have completed; inspect before retry.' : clean(commands[list.getSelectedIndex()]?.disabled || 'Enter opens action · Esc closes · arrows / PgDn scroll'); };
const explain = () => { reason.content = this.pending ? 'Working… Esc stops waiting. Remote work or saved changes may already be complete. Inspect before you try again.' : clean(commands[list.getSelectedIndex()]?.disabled || 'Enter opens · Esc closes · arrows/PgDn scroll'); };
explain(); list.on('selectionChanged', explain);
const cancel = () => { this.modal = undefined; box.destroyRecursively(); [this.list, this.scroll, this.actions][this.focusIndex]!.focus(); };
list.on('itemSelected', (i: number) => { if (this.pending || commands[i]?.disabled) { explain(); return; } cancel(); void this.run(i, commands[i]); });
@@ -196,8 +215,8 @@ export class OpenTuiScreen {
? new TextareaRenderable(this.renderer, { initialValue: value, flexGrow: 1, width: '100%' })
: new InputRenderable(this.renderer, { value, maxLength: 8192, width: '100%', backgroundColor: fg, textColor: bg, focusedBackgroundColor: fg, focusedTextColor: bg });
if (entry) { const field = new BoxRenderable(this.renderer, { border: !multiline, height: multiline ? undefined : 3, flexShrink: 0, flexGrow: multiline ? 1 : 0, width: '100%' }); field.add(entry); box.add(field); entry.focus(); }
else { this.list.blur(); this.actions.blur(); this.scroll.blur(); box.add(new TextRenderable(this.renderer, { fg, content: 'Input hidden — no characters or length displayed.', height: 2 })); }
box.add(new TextRenderable(this.renderer, { fg, content: multiline ? 'Ctrl-S save · Esc cancel (unsaved text discarded)' : back ? 'Enter next · Shift-Tab back · Esc cancel' : 'Enter accept · Esc cancel · Ctrl-↑/↓ text', height: 2 }));
else { this.list.blur(); this.actions.blur(); this.scroll.blur(); box.add(new TextRenderable(this.renderer, { fg, content: 'Your key is hidden. Its length is not shown.', height: 2 })); }
box.add(new TextRenderable(this.renderer, { fg, content: multiline ? 'Ctrl-S save · Esc cancel (unsaved text discarded)' : back ? 'Enter next · Shift-Tab back · Esc cancel' : 'Enter continue · Esc cancel · Ctrl-↑/↓ scroll', height: 2 }));
const error = new TextRenderable(this.renderer, { fg, height: 2, flexShrink: 0 }); box.add(error);
let settled = false;
const finish = (answer?: string) => {
@@ -216,7 +235,7 @@ export class OpenTuiScreen {
if (back && !secret && key.name === 'tab' && key.shift) { key.preventDefault(); back(entry instanceof InputRenderable ? entry.value : entry?.plainText ?? ''); finish('\0back'); return; }
if (key.name === 'escape') { key.preventDefault(); finish(); return; }
if ((!multiline && key.name === 'return') || (multiline && key.ctrl && key.name === 's')) {
key.preventDefault(); const answer = secret ? hidden : entry instanceof InputRenderable ? entry.value : entry?.plainText ?? ''; const invalid = validate?.(answer); if (invalid) { error.content = secret ? 'Invalid key. Re-enter.' : clean(invalid); if (secret) hidden = ''; return; } finish(answer); return;
key.preventDefault(); const answer = secret ? hidden : entry instanceof InputRenderable ? entry.value : entry?.plainText ?? ''; const invalid = validate?.(answer); if (invalid) { error.content = secret ? 'Invalid key. Enter the key again.' : clean(invalid); if (secret) hidden = ''; return; } finish(answer); return;
}
if (!secret) return;
key.preventDefault();
@@ -227,7 +246,7 @@ export class OpenTuiScreen {
};
});
}
async confirm(label: string) { return await this.input(`${label}\nType yes to confirm. Empty Enter is Cancel.`) === 'yes'; }
async confirm(label: string) { return await this.input(`${label}\nType yes to confirm. Press Enter with no text to cancel.`) === 'yes'; }
close() {
if (this.closed) return; this.closed = true; this.modal?.cancel(); this.renderer.destroy(); this.finish();
}

View File

@@ -39,7 +39,7 @@ test('manager config save is real/keyless owner routing; retained configuration
await f.controller.request({ id: 1, action: 'configure', values: { owner, relay: 'wss://example.invalid' } });
assert.ok(existsSync(join(f.home,'.beehive','host','host-identity.json')));
assert.match(f.snapshot.status,/saved/);
assert.match(f.snapshot.local[0]!.detail, /Service: not checked/);
assert.match(f.snapshot.local[0]!.detail, /Host service: not checked/);
assert.ok(!f.snapshot.local[0]!.detail.includes('{'));
assert.match(f.snapshot.local[0]!.evidence!, /nonce/);
assert.equal(f.backend.read(credentialReference('owner',owner)),null);
@@ -61,22 +61,22 @@ test('manager gates owner, exact selection/revision/freshness, unresolved operat
await request('signin',{ values: { owner, relay: 'wss://example.invalid', secret } }); assert.equal(f.snapshot.owner,owner);
const m = message('inventory','host-a','agent-a',3,{ observedAt: Date.now(), phase: 'stopped', actualRun: null, assignedHost: 'host-a', configurations: { default: {} }, selectedNext: {} });
f.receive(m);
assert.match(f.snapshot.agents[0]!.detail, /Actual run \(reported\)/);
assert.match(f.snapshot.agents[0]!.detail, /Selected for next Start/);
assert.match(f.snapshot.agents[0]!.detail, /Current run \(host report\)/);
assert.match(f.snapshot.agents[0]!.detail, /Configuration for next start/);
assert.ok(!f.snapshot.agents[0]!.detail.includes('{'));
assert.equal(f.snapshot.agents[0]!.disabled!.start, '');
const target = JSON.stringify(['host-a','agent-a']);
await request('start',{ target, revision: 2 }); assert.match(f.snapshot.status,/Selection changed/);
await request('start',{ target, revision: 2 }); assert.match(f.snapshot.status,/selection changed/);
await request('start',{ target, revision: 3 }); assert.equal(f.submitted.length,1); assert.equal(f.submitted[0]!.type,'start');
await f.controller.request({ id, action: 'start', target, revision: 3 }); assert.equal(f.submitted.length,1,'duplicate request cannot act twice');
await request('restart',{ target, revision: 3 }); assert.match(f.snapshot.status,/Not available/); assert.equal(f.submitted.length,1,'handler refuses even if directly invoked');
await request('restart',{ target, revision: 3 }); assert.match(f.snapshot.status,/No Restart request is sent/); assert.equal(f.submitted.length,1,'handler refuses even if directly invoked');
f.states([{ request: f.submitted[0], state: 'unknown' }]);
await request('stop',{ target, revision: 3 }); assert.match(f.snapshot.status,/Unresolved/); assert.equal(f.submitted.length,1);
await request('stop',{ target, revision: 3 }); assert.match(f.snapshot.status,/no confirmed result/); assert.equal(f.submitted.length,1);
f.states([{ request: f.submitted[0], state: 'completed' }]);
await request('select-config',{ target, revision: 3, values: { name: 'Alternative' } }); assert.equal(f.submitted[1]!.type,'save');
await request('stop',{ target, revision: 3 }); assert.equal(f.submitted[2]!.type,'stop');
f.receive({ ...m, revision: 4, body: { ...m.body, observedAt: Date.now()+1, phase: 'running', actualRun: { run: 'actual' } } });
await request('start',{ target, revision: 4 }); assert.match(f.snapshot.status,/fresh assigned stopped/);
await request('start',{ target, revision: 4 }); assert.match(f.snapshot.status,/recent report from the assigned host/);
await request('signout'); assert.equal(f.snapshot.owner,undefined); assert.equal(f.submitted.length,3); assert.equal(f.closed,1);
f.receive(m); assert.equal(f.controller.snapshot().agents.length,0,'late receive is fenced after signout');
} finally { f.cleanup(); }
@@ -111,3 +111,37 @@ test('credential orchestration uses explicit Node, strips loaders, and awaits ow
assert.throws(() => process.kill(pid,0),{ code: 'ESRCH' });
} finally { abort.abort(); rmSync(home,{ recursive: true, force: true }); }
});
test('manager presentation preserves raw evidence, unknown states and literal configuration values', async () => {
const f = fixture();
try {
await f.controller.request({ id: 1, action: 'signin', values: { owner, relay: 'wss://example.invalid', secret } });
const m = message('inventory','host-a','agent-a',3,{ observedAt: Date.now() - 10000, phase: 'running', assignedHost: 'host-a', actualRun: { selection: { model: 'accepted', workspace: 'unconfirmed', profile: 'default' }, future: { diagnostic: 'raw detail' } }, selectedNext: { configuration: { name: 'accepted', revision: 2 }, harnessSetup: { id: 'setup', fingerprint: 'fingerprint' } } });
f.receive(m);
const row = f.snapshot.agents[0]!;
assert.match(row.label, /Unknown/);
assert.match(row.detail, /Current status unknown/);
assert.match(row.detail, /Reported status: running/);
assert.match(row.detail, /Model: accepted\nWorkspace: unconfirmed/);
assert.match(row.detail, /Configuration name: accepted/);
assert.match(row.detail, /future: diagnostic: raw detail/);
assert.deepEqual(JSON.parse(row.evidence!), m);
assert.match(row.disabled!.start!, /host report is old/);
const request = message('save','host-a','agent-a',3,{ configurationAction: 'select', name: 'accepted' });
const operation = { request, state: 'unknown', publication: 'relay policy failure; automatic retry disabled; reconcile, then retry after policy repair', result: 'new backend diagnostic' };
f.states([operation]); f.controller.refresh();
const shown = f.snapshot.agents.find(r => r.id === `operation:${request.id}`)!;
assert.match(shown.label, /Choose configuration · Unknown/);
assert.match(shown.detail, /Automatic retry is disabled/);
assert.match(shown.detail, /Host result: new backend diagnostic/);
assert.deepEqual(JSON.parse(shown.evidence!), operation);
await f.controller.request({ id: 2, action: 'operations' });
assert.match(f.snapshot.status, /Choose configuration · Unknown: new backend diagnostic/);
await f.controller.request({ id: 3, action: 'reconcile' });
assert.equal(f.snapshot.status, 'Checking operation results. Operations blocked by relay policy will not be retried.');
assert.equal(f.submitted.length, 0);
assert.equal(operation.state, 'unknown');
f.receive(message('availability','host-b','',0,{ observedAt: Date.now(), configuration: { label: 'Host B', relay: 'wss://example.invalid' } }));
assert.match(f.snapshot.agents.find(r => r.id === 'host:host-b')!.detail, /Host configuration: Host name: Host B/);
} finally { f.cleanup(); }
});

View File

@@ -8,9 +8,9 @@ const view = new OpenTuiScreen(ui.renderer);
const save = async (name: string) => { await ui.renderOnce(); writeFileSync(output + name + '.txt', ui.captureCharFrame()); writeFileSync(output + name + '-spans.json', JSON.stringify(ui.captureSpans())); };
try {
ui.mockInput.pressArrow('right');
view.show([{ id: 'synthetic-agent', label: 'Agent 12345678…abcdef', detail: 'Agent 12345678…abcdef\nHost abcdef12…123456\nUNKNOWN — stale or unreachable; last reported below\nReported phase: running\n\nActual run (reported)\nConfiguration: daily\nModel: Not reported\n\nSelected for next Start\nConfiguration: careful\nChoosing next does not change the actual run.', evidence: '{"syntheticPublicEvidence":true}' }], [{ label: 'Start agent', disabled: 'Host stale or unreachable. Refresh before acting.', run: () => { throw Error('Must never run'); } }, { label: 'Restart', disabled: 'Backend Restart may switch bindings. Use Stop, inspect accepted stopped report, then Start. No Restart request is sent.', run: () => { throw Error('Must never run'); } }]);
view.show([{ id: 'synthetic-agent', label: 'Agent 12345678…abcdef', detail: 'Agent 12345678…abcdef\nHost abcdef12…123456\nCurrent status unknown. The report is old or the host cannot be reached. Last report:\nReported status: running\n\nCurrent run (host report)\nConfiguration: daily\nModel: Not reported\n\nConfiguration for next start\nConfiguration: careful\nThis choice does not change the current run.', evidence: '{"syntheticPublicEvidence":true}' }], [{ label: 'Start agent', disabled: 'The host report is old or the host cannot be reached. Wait for a recent report before you act.', run: () => { throw Error('Must never run'); } }, { label: 'Restart', disabled: 'Restart can change the local setup. Use Stop. Inspect a recent host report that confirms the agent is stopped. Then use Start. No Restart request is sent.', run: () => { throw Error('Must never run'); } }]);
await save('fixture-agent');
view.setPending(true); view.notice('Working: synthetic request… Esc cancels waiting, not committed OS writes or remote work.');
ui.mockInput.pressKey('F2'); await save('fixture-pending');
view.setPending(true); view.notice('Working… Esc stops waiting. Remote work and saved changes are not cancelled. Inspect before you try again.');
ui.mockInput.pressKey('a'); await save('fixture-pending');
ui.mockInput.pressKey('q', { ctrl: true });
} finally { view.close(); }

View File

@@ -12,6 +12,10 @@ test('actual OpenTUI selection, hidden entry, multiline save, resize and quit',
view.show([{ id: 'a', label: 'Synthetic A', detail: 'First detail' }, { id: 'b', label: 'Synthetic B', detail: 'Second detail' }], []);
await ui.renderOnce();
assert.match(ui.captureCharFrame(), /Local Host.*Agents/);
assert.match(ui.captureCharFrame(), /\? Help · a Actions · i Inspect · o Status · q Quit/, 'ordinary-key footer, no function keys');
ui.resize(44, 24); await ui.renderOnce();
assert.match(ui.captureCharFrame(), /\? a Actions i Inspect o Status q Quit/, 'compact ordinary-key footer fits the 40-column floor');
ui.resize(60, 24); await ui.renderOnce();
ui.mockInput.pressArrow('down');
await ui.renderOnce();
assert.equal(selected, 'b');
@@ -33,11 +37,15 @@ test('actual OpenTUI selection, hidden entry, multiline save, resize and quit',
ui.mockInput.pressKey('s', { ctrl: true });
assert.equal(await edit, 'line one');
ui.resize(30, 10); await ui.renderOnce();
assert.match(ui.captureCharFrame(), /Terminal too small/);
assert.match(ui.captureCharFrame(), /Resize to at least 40 × 16/);
ui.resize(60, 24); await ui.renderOnce();
assert.match(ui.captureCharFrame(), /Local Host.*Agents/);
const quitting = view.input('Quit clears volatile input', '', true);
await ui.mockInput.typeText(secret);
await ui.mockInput.typeText('aq?io');
ui.mockInput.pressKey('q'); // plain q inside a secret form types text; it must never quit or fire actions
await ui.renderOnce();
assert.equal(await Promise.race([view.done.then(() => 'quit'), new Promise(resolve => setTimeout(resolve, 120, 'open'))]), 'open', 'plain q must not quit an open form');
assert.ok(!ui.captureCharFrame().includes('[Actions]'), 'a must not open the drawer from a secret form');
ui.mockInput.pressKey('q', { ctrl: true });
await view.done;
assert.equal(await quitting, undefined);
@@ -55,6 +63,9 @@ test('drawer guards, persistent keys, pending Esc priority, list navigation and
{label: 'Start', disabled: 'Requires fresh stopped report.', run: () => { calls++; }},
...Array.from({length: 10}, (_, i) => ({label: `Action ${i}`, run: () => { calls++; }})),
]);
await ui.renderOnce();
assert.match(ui.captureCharFrame(), /Actions \(a\)/, 'visible named control shows the ordinary key');
assert.match(ui.captureCharFrame(), /Inspect \(i\)/);
ui.mockInput.pressKey('HOME'); await ui.renderOnce();
await ui.mockMouse.scroll(4, 5, 'down'); await ui.renderOnce();
assert.equal(selected, '1');
@@ -63,7 +74,7 @@ test('drawer guards, persistent keys, pending Esc priority, list navigation and
await ui.mockMouse.click(4, 5); await ui.renderOnce();
assert.equal(selected, '24', 'scrolled click cannot select wrong row');
ui.mockInput.pressEnter(); assert.equal(calls, 0);
ui.mockInput.pressKey('F2'); await ui.renderOnce();
ui.mockInput.pressKey('a'); await ui.renderOnce();
assert.match(ui.captureCharFrame(), /\(unavailable\) Start/);
assert.match(ui.captureCharFrame(), /Requires fresh stopped report/);
ui.mockInput.pressEnter(); assert.equal(calls, 0);
@@ -71,14 +82,14 @@ test('drawer guards, persistent keys, pending Esc priority, list navigation and
assert.match(ui.captureCharFrame(), /Action 9/);
ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50));
view.setPending(true); view.notice('Working: synthetic pending request');
ui.mockInput.pressKey('F2'); await ui.renderOnce();
assert.match(ui.captureCharFrame(), /Esc cancels waiting/);
assert.match(ui.captureCharFrame(), /F1 Keys F2 Actions F3 Inspect/);
ui.mockInput.pressKey('a'); await ui.renderOnce();
assert.match(ui.captureCharFrame(), /Esc stops waiting/);
assert.match(ui.captureCharFrame(), /\? Help · a Actions · i Inspect · o Status · q Quit/);
ui.mockInput.pressEnter(); assert.equal(calls, 0);
ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50)); assert.equal(cancelled, 1);
await ui.renderOnce(); assert.match(ui.captureCharFrame(), /\[Actions\]/);
view.setPending(false); ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50));
ui.mockInput.pressKey('F3'); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /PUBLIC 24/);
ui.mockInput.pressKey('i'); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /PUBLIC 24/);
ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50));
const input = view.input('Required public field', '', false, false, value => value ? '' : 'Required; enter a value.');
ui.mockInput.pressEnter(); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /Required; enter a value/);
@@ -86,7 +97,48 @@ test('drawer guards, persistent keys, pending Esc priority, list navigation and
ui.resize(60,24); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /retained public input/);
ui.mockInput.pressEnter(); assert.equal(await input, 'retained public input');
view.notice('FAILED: a long outcome\n' + 'Details\n'.repeat(40));
ui.mockInput.pressKey('F4'); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /FAILED: a long outcome/);
assert.match(ui.captureCharFrame(), /F1 Keys F2 Actions F3 Inspect/);
ui.mockInput.pressKey('o'); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /FAILED: a long outcome/);
assert.match(ui.captureCharFrame(), /\? Help · a Actions · i Inspect · o Status · q Quit/);
ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50));
ui.mockInput.pressKey('?'); await ui.renderOnce();
assert.match(ui.captureCharFrame(), /Help · Esc close/);
assert.match(ui.captureCharFrame(), /Shortcuts do not run while you edit a field/);
ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50));
// Legacy F-key compatibility aliases remain functional, undocumented in footer/help.
ui.mockInput.pressKey('F2'); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /\[Actions\]/);
ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50));
ui.mockInput.pressKey('F3'); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /PUBLIC 24/);
ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50));
ui.mockInput.pressKey('q');
assert.equal(await Promise.race([view.done.then(() => 'quit'), new Promise(resolve => setTimeout(resolve, 2000, 'open'))]), 'quit', 'plain q quits outside editable contexts');
} finally { view.close(); }
});
test('ordinary shortcut characters type as text in public, secret and multiline inputs', async () => {
const ui = await createTestRenderer({ width: 100, height: 30, exitOnCtrlC: false });
const view = new OpenTuiScreen(ui.renderer);
try {
view.show([{ id: 'row', label: 'Row', detail: 'Detail', evidence: 'EVIDENCE' }], []);
await ui.renderOnce();
const publicField = view.input('Public field', '', false, false);
await ui.mockInput.typeText('a?ioq');
await ui.renderOnce();
assert.ok(!ui.captureCharFrame().includes('[Actions]'), 'a must not open the drawer from a public form');
assert.ok(!ui.captureCharFrame().includes('Technical details'), 'i must not open inspect from a public form');
assert.ok(!ui.captureCharFrame().includes('No status message yet'), 'o must not open the outcome overlay from a public form');
ui.mockInput.pressEnter();
assert.equal(await publicField, 'a?ioq');
const secret = view.input('Hidden field', '', true);
await ui.renderOnce();
assert.ok(!ui.captureCharFrame().includes('a?ioq'), 'prior public field value not left on screen');
await ui.mockInput.typeText('a?ioq');
ui.mockInput.pressEnter();
assert.equal(await secret, 'a?ioq');
await ui.renderOnce();
assert.ok(!ui.captureCharFrame().includes('a?ioq'), 'hidden value never rendered');
const draft = view.input('Multiline draft', '', false, true);
await ui.mockInput.typeText('a io q?');
ui.mockInput.pressKey('s', { ctrl: true });
assert.equal(await draft, 'a io q?');
} finally { view.close(); }
});