@axplusb/kepler 1.0.10 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Approval prompt UI — Mission Control (PRD-055 §8.2).
3
+ *
4
+ * ┃ AWAITING APPROVAL ┃
5
+ * ┃ ┃
6
+ * ┃ ⚙️ shell "rm -rf node_modules" ┃
7
+ * ┃ ┃
8
+ * ┃ Tier: SHELL-DANGEROUS ┃
9
+ * ┃ Why: Recursive delete in project directory ┃
10
+ * ┃ ┃
11
+ * ┃ [Enter] approve [e] edit [r] re-plan [n] reject [?] why
12
+ *
13
+ * The border colour is `brand.accent` (magenta) for explicit-approval
14
+ * tiers; safe-default prompts use `brand.data` so they read as advisory.
15
+ *
16
+ * Pure — caller writes the returned string to stderr.
17
+ */
18
+
19
+ import { paint, width as visibleWidth } from './palette.mjs';
20
+ import { icon } from './icons.mjs';
21
+ import { toolDisplayLabel, toolDisplaySummary } from '../terminal/tool-display.mjs';
22
+ import { label as tierLabel, requiresExplicitApproval, TIERS } from '../core/risk-tier.mjs';
23
+
24
+ const VBAR = '┃';
25
+ const PAD_X = 3;
26
+
27
+ /**
28
+ * Default option set per tier. Caller can override via `opts.options`.
29
+ *
30
+ * Each option is `{ key, label, value, hint? }`:
31
+ * key — single-letter shortcut (case-insensitive)
32
+ * label — text shown in the menu
33
+ * value — return value from the menu loop
34
+ * hint — secondary description shown to the right of the label
35
+ */
36
+ export function defaultOptions(tier) {
37
+ if (requiresExplicitApproval(tier)) {
38
+ return [
39
+ { key: 'y', label: 'approve', value: 'approve', hint: 'run this once' },
40
+ { key: 'e', label: 'edit', value: 'edit', hint: 'tweak the args before running' },
41
+ { key: 'r', label: 're-plan', value: 'replan', hint: 'send back to the agent' },
42
+ { key: 'n', label: 'reject', value: 'reject', hint: 'do not run' },
43
+ { key: '?', label: 'why', value: 'why', hint: 'show reasoning' },
44
+ ];
45
+ }
46
+ return [
47
+ { key: 'y', label: 'approve', value: 'approve', hint: 'run this once' },
48
+ { key: 't', label: 'always allow', value: 'allow-type', hint: 'auto-approve future calls to this tool' },
49
+ { key: 'n', label: 'reject', value: 'reject', hint: 'do not run' },
50
+ { key: '?', label: 'why', value: 'why', hint: 'show reasoning' },
51
+ ];
52
+ }
53
+
54
+ /**
55
+ * Render the bordered prompt with vertical, arrow-navigable options.
56
+ *
57
+ * ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
58
+ * ┃ AWAITING APPROVAL ┃
59
+ * ┃ ┃
60
+ * ┃ ⚙️ shell "rm -rf …" ┃
61
+ * ┃ ┃
62
+ * ┃ Tier: SHELL-DANGEROUS ┃
63
+ * ┃ Why: Recursive delete… ┃
64
+ * ┃ ┃
65
+ * ┃ ▸ [y] approve ┃ ← selected (accent)
66
+ * ┃ [e] edit ┃
67
+ * ┃ [r] re-plan ┃
68
+ * ┃ [n] reject ┃
69
+ * ┃ [?] why ┃
70
+ * ┃ ┃
71
+ * ┃ ↑↓ move · Enter pick ┃
72
+ * ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
73
+ *
74
+ * @param {object} opts
75
+ * @param {string} opts.tool
76
+ * @param {object} opts.args
77
+ * @param {string} opts.tier
78
+ * @param {string} [opts.why]
79
+ * @param {number} [opts.width]
80
+ * @param {Array} [opts.options] — override default option set
81
+ * @param {number} [opts.selected] — index of the highlighted option
82
+ */
83
+ export function renderApprovalPrompt({
84
+ tool, args = {}, tier, why = '', width,
85
+ options, selected = 0,
86
+ } = {}) {
87
+ const cols = Math.max(60, Math.min(width || process.stderr.columns || 96, 120));
88
+ const explicit = requiresExplicitApproval(tier);
89
+ const accent = explicit ? paint.brand.accent : paint.brand.data;
90
+ const opts = options || defaultOptions(tier);
91
+
92
+ const summary = toolDisplaySummary(tool, args, {});
93
+ const toolLine = `${icon(tool)} ${paint.text.primary(toolDisplayLabel(tool))} ${paint.text.muted(summary ? `"${truncate(summary, cols - 20)}"` : '')}`;
94
+
95
+ const lines = [
96
+ bar(cols, accent),
97
+ fill(' AWAITING APPROVAL', cols, accent, paint.bold(accent('AWAITING APPROVAL'))),
98
+ fill('', cols, accent),
99
+ fill(' ' + ' '.repeat(PAD_X - 1) + toolLine, cols, accent),
100
+ fill('', cols, accent),
101
+ fill(' ' + ' '.repeat(PAD_X - 1) + paint.text.dim('Tier: ') + accent(tierLabel(tier)), cols, accent),
102
+ ];
103
+ if (why) {
104
+ lines.push(fill(' ' + ' '.repeat(PAD_X - 1) + paint.text.dim('Why: ') + paint.text.primary(truncate(why, cols - PAD_X - 12)), cols, accent));
105
+ }
106
+ lines.push(fill('', cols, accent));
107
+
108
+ for (let i = 0; i < opts.length; i++) {
109
+ const o = opts[i];
110
+ const isSel = i === selected;
111
+ const cursor = isSel ? accent('▸ ') : paint.text.dim(' ');
112
+ const keyTag = paint.text.dim('[') + (isSel ? accent(o.key) : paint.brand.data(o.key)) + paint.text.dim('] ');
113
+ const label = isSel ? paint.bold(accent(o.label)) : paint.text.primary(o.label);
114
+ const hint = o.hint ? ' ' + paint.text.muted(o.hint) : '';
115
+ lines.push(fill(' ' + ' '.repeat(PAD_X - 1) + cursor + keyTag + label + hint, cols, accent));
116
+ }
117
+
118
+ lines.push(fill('', cols, accent));
119
+ lines.push(fill(' ' + ' '.repeat(PAD_X - 1) +
120
+ paint.text.dim('↑↓ ') + paint.brand.data('move') +
121
+ paint.text.dim(' · Enter ') + paint.brand.data('pick') +
122
+ paint.text.dim(' · or press a letter'), cols, accent));
123
+ lines.push(bar(cols, accent));
124
+
125
+ return '\n' + lines.join('\n');
126
+ }
127
+
128
+ function bar(width, painter) {
129
+ // Top / bottom rule: a magenta vertical-stack line spanning the full width.
130
+ return painter('▔'.repeat(width));
131
+ }
132
+
133
+ function fill(text, width, painter, override) {
134
+ const visible = override ?? text;
135
+ const pad = Math.max(0, width - 2 - visibleWidth(visible));
136
+ return painter(VBAR) + visible + ' '.repeat(pad) + painter(VBAR);
137
+ }
138
+
139
+ function truncate(text, n) {
140
+ const s = String(text || '');
141
+ if (s.length <= n) return s;
142
+ return s.slice(0, Math.max(0, n - 1)) + '…';
143
+ }
144
+
145
+ // ── Inline (safe-default) prompt for shell-medium / network ────────────
146
+
147
+ /**
148
+ * Render the compact one-line prompt used for safe-default tiers.
149
+ * The bordered block is reserved for explicit-approval tiers.
150
+ *
151
+ * ? Run `npm install lodash`? Tier: SHELL-MEDIUM [Enter=yes n=no ?=why]
152
+ */
153
+ export function renderInlinePrompt({ tool, args = {}, tier, why = '' } = {}) {
154
+ const summary = toolDisplaySummary(tool, args, {});
155
+ const label = toolDisplayLabel(tool);
156
+ const lines = [];
157
+ const head = `${paint.brand.data('?')} ${paint.text.primary(label)} ${paint.text.muted(summary ? `"${truncate(summary, 80)}"` : '')}`;
158
+ lines.push(' ' + head);
159
+ lines.push(' ' + paint.text.dim('Tier ') + paint.brand.data(tierLabel(tier)) +
160
+ (why ? ' ' + paint.text.dim('Why ') + paint.text.muted(truncate(why, 60)) : ''));
161
+ lines.push(' ' + paint.text.dim('[') + paint.brand.data('Enter') + paint.text.dim('=yes ') +
162
+ paint.brand.data('n') + paint.text.dim('=no ') +
163
+ paint.brand.data('?') + paint.text.dim('=why]'));
164
+ return '\n' + lines.join('\n');
165
+ }
166
+
167
+ export { TIERS };
package/src/ui/banner.mjs CHANGED
@@ -1,162 +1,173 @@
1
1
  /**
2
2
  * Banner & Branding — Kepler CLI startup display.
3
+ *
4
+ * Refreshed for Mission Control (PRD-055 §4.3). Uses the semantic palette
5
+ * (`paint.brand.*`) so the same banner renders correctly across truecolor,
6
+ * 256-color, ansi16, and monochrome terminals.
3
7
  */
4
8
 
5
9
  import { execSync } from 'node:child_process';
6
10
  import * as path from 'node:path';
7
11
 
8
- // ANSI color helpers
9
- const BOLD = '\x1b[1m';
10
- const DIM = '\x1b[2m';
11
- const RESET = '\x1b[0m';
12
- const GREEN = '\x1b[32m';
13
- const CYAN = '\x1b[36m';
14
- const YELLOW = '\x1b[33m';
15
- const RED = '\x1b[31m';
16
- const BLUE = '\x1b[34m';
17
- const BOLD_GREEN = '\x1b[1;32m';
18
- const BOLD_CYAN = '\x1b[1;36m';
12
+ import { icons } from './icons.mjs';
13
+ import { paint, strip } from './palette.mjs';
14
+ import { term } from './term.mjs';
15
+
16
+ const out = process.stderr;
17
+ const write = (s) => { try { out.write(s); } catch {} };
18
+
19
+ // ── Brand banner ─────────────────────────────────────────────────────────
20
+
21
+ const KEPLER_LETTERS = ['K', 'E', 'P', 'L', 'E', 'R'];
22
+
23
+ /**
24
+ * Render `KEPLER` letter-by-letter as a purple→magenta→cyan gradient.
25
+ * Each letter picks the appropriate brand token; the palette handles tier
26
+ * fallbacks transparently.
27
+ *
28
+ * Falls back to a single solid color in monochrome / ascii mode so we
29
+ * still see something distinctive on hostile terminals.
30
+ */
31
+ function gradientKepler() {
32
+ if (!term().color) return KEPLER_LETTERS.join(' · ');
33
+
34
+ // Three-stop gradient mapped onto six letters. Stop selection:
35
+ // 0,1 → primary 2,3 → accent 4,5 → data
36
+ const painters = [
37
+ paint.brand.primary, paint.brand.primary,
38
+ paint.brand.accent, paint.brand.accent,
39
+ paint.brand.data, paint.brand.data,
40
+ ];
41
+ return KEPLER_LETTERS.map((ch, i) => painters[i](ch)).join(paint.text.dim(' · '));
42
+ }
19
43
 
20
44
  /**
21
45
  * Print the branded orbital banner.
22
46
  */
23
47
  export function printBanner() {
24
- process.stderr.write('\n');
25
- process.stderr.write(`${DIM} ✦${RESET}\n`);
26
- process.stderr.write(`${DIM} ╭──────────────────────────╮${RESET}\n`);
27
- process.stderr.write(`${DIM} │${RESET} ${BOLD}${CYAN}K · E · P · L · E · R${RESET} ${DIM}│${RESET}\n`);
28
- process.stderr.write(`${DIM} ╰──────── ${YELLOW}◯${RESET}${DIM} ───────────────╯${RESET}\n`);
29
- process.stderr.write(`${DIM} ╱ ╲${RESET}\n`);
30
- process.stderr.write(`${DIM} the agentic os${RESET}\n`);
31
- process.stderr.write('\n');
48
+ const dim = paint.text.dim;
49
+ const brandMark = paint.brand.primary(icons.brand);
50
+ const orbit = paint.brand.accent(icons.orbit);
51
+
52
+ write('\n');
53
+ write(` ${brandMark}\n`);
54
+ write(` ${dim('╭───────────────────────────╮')}\n`);
55
+ write(` ${dim('│')} ${gradientKepler()} ${dim('│')}\n`);
56
+ write(` ${dim('╰────── ')}${orbit}${dim(' ─────────────────╯')}\n`);
57
+ write(` ${dim('╱ ╲')}\n`);
58
+ write(` ${dim('the agentic os')}\n`);
59
+ write('\n');
32
60
  }
33
61
 
62
+ // ── Project info bar ─────────────────────────────────────────────────────
63
+
64
+ const BAR_WIDTH = 60;
65
+
34
66
  /**
35
67
  * Print project info bar with version, project name, and git info.
36
- * @param {string} version
37
68
  */
38
69
  export function printProjectInfo(version) {
39
- const cwd = process.cwd();
40
- const projectName = path.basename(cwd);
41
- const gitInfo = getGitInfo(cwd);
42
-
43
- const sep = `${DIM}${RESET}`;
44
- let info = ` ${DIM}v${version}${RESET}${sep}${BOLD}${projectName}${RESET}`;
45
- if (gitInfo) {
46
- info += `${sep}${YELLOW}${gitInfo}${RESET}`;
47
- }
48
-
49
- // Draw a boxed info bar
50
- const barWidth = 60;
51
- const topBorder = `${BLUE}┌${'─'.repeat(barWidth)}┐${RESET}`;
52
- const bottomBorder = `${BLUE}└${'─'.repeat(barWidth)}┘${RESET}`;
53
-
54
- process.stderr.write(`${topBorder}\n`);
55
- process.stderr.write(`${BLUE}│${RESET} ${info}${' '.repeat(Math.max(0, barWidth - stripAnsi(info).length - 1))}${BLUE}│${RESET}\n`);
56
- process.stderr.write(`${bottomBorder}\n`);
70
+ const cwd = process.cwd();
71
+ const projectName = path.basename(cwd);
72
+ const gitInfo = getGitInfo(cwd);
73
+
74
+ const sep = paint.text.dim('');
75
+ let info = ` ${paint.text.dim('v' + version)}${sep}${paint.bold(projectName)}`;
76
+ if (gitInfo) {
77
+ info += `${sep}${paint.state.warn(gitInfo)}`;
78
+ }
79
+
80
+ const padCount = Math.max(0, BAR_WIDTH - strip(info).length - 1);
81
+ const border = paint.brand.primary;
82
+
83
+ write(`${border('┌' + '─'.repeat(BAR_WIDTH) + '┐')}\n`);
84
+ write(`${border('│')} ${info}${' '.repeat(padCount)}${border('│')}\n`);
85
+ write(`${border('└' + '─'.repeat(BAR_WIDTH) + '┘')}\n`);
57
86
  }
58
87
 
59
- /**
60
- * Print keyboard hints and usage tips.
61
- */
62
- export function printHints() {
63
- const env = process.env.TARANG_ENV || process.env.NODE_ENV || 'production';
88
+ // ── Hints ────────────────────────────────────────────────────────────────
64
89
 
65
- process.stderr.write(`${GREEN}Type your instructions${RESET}, or ${CYAN}/help${RESET} for commands\n`);
66
- process.stderr.write(`${DIM}Ctrl+C${RESET}${DIM}=exit ${RESET}${DIM}/clear${RESET}${DIM}=reset ${RESET}${DIM}/config${RESET}${DIM}=settings ${RESET}${DIM}/login${RESET}${DIM}=auth${RESET}\n`);
67
- process.stderr.write(`${DIM}env:${env} models:configured via browser (/config)${RESET}\n`);
68
- process.stderr.write('\n');
90
+ export function printHints() {
91
+ const env = process.env.TARANG_ENV || process.env.NODE_ENV || 'production';
92
+ const dim = paint.text.dim;
93
+ const accent = paint.brand.data;
94
+
95
+ write(`${paint.state.success('Type your instructions')}, or ${accent('/help')} for commands\n`);
96
+ write(`${dim('Ctrl+C')}${dim('=exit ')}${dim('/clear')}${dim('=reset ')}${dim('/config')}${dim('=settings ')}${dim('/login')}${dim('=auth')}\n`);
97
+ write(`${dim('env:' + env + ' models:configured via browser (/config)')}\n`);
98
+ write('\n');
69
99
  }
70
100
 
71
- /**
72
- * Print auth status indicators.
73
- * @param {object} creds - { token, openRouterKey, anthropicKey, backendUrl, mode }
74
- */
75
- export function printAuthStatus(creds) {
76
- const check = `${GREEN}✓${RESET}`;
77
- const cross = `${RED}✗${RESET}`;
101
+ // ── Auth + config ────────────────────────────────────────────────────────
78
102
 
79
- const tokenOk = !!creds.token;
80
- const env = process.env.TARANG_ENV || process.env.NODE_ENV || 'production';
81
-
82
- process.stderr.write(` Auth: ${tokenOk ? `${check} logged in ${DIM}(/whoami for details)${RESET}` : `${cross} not logged in ${DIM}(/login)${RESET}`}\n`);
83
- process.stderr.write(` Env: ${DIM}${env}${RESET}\n`);
84
- process.stderr.write(` Mode: ${DIM}${creds.mode || 'auto'}${RESET}\n`);
85
- process.stderr.write('\n');
103
+ export function printAuthStatus(creds) {
104
+ const check = paint.state.success(icons.pass);
105
+ const cross = paint.state.danger(icons.fail);
106
+ const dim = paint.text.dim;
107
+
108
+ const tokenOk = !!creds.token;
109
+ const env = process.env.TARANG_ENV || process.env.NODE_ENV || 'production';
110
+
111
+ write(` Auth: ${tokenOk
112
+ ? `${check} logged in ${dim('(/whoami for details)')}`
113
+ : `${cross} not logged in ${dim('(/login)')}`}\n`);
114
+ write(` Env: ${dim(env)}\n`);
115
+ write(` Mode: ${dim(creds.mode || 'auto')}\n`);
116
+ write('\n');
86
117
  }
87
118
 
88
- /**
89
- * Print config in a styled format.
90
- * @param {object} creds
91
- */
92
119
  export function printStyledConfig(creds) {
93
- const check = `${GREEN}✓${RESET}`;
94
- const cross = `${RED}✗${RESET}`;
95
- const mask = (val) => {
96
- if (!val) return `${cross} not set`;
97
- if (val.length <= 8) return `${check} ****`;
98
- return `${check} ${val.slice(0, 6)}...${val.slice(-4)}`;
99
- };
100
-
101
- process.stderr.write(`\n${BOLD}Kepler Configuration${RESET} ${DIM}(~/.kepler/config.json)${RESET}\n`);
102
- process.stderr.write(`${'─'.repeat(50)}\n`);
103
- process.stderr.write(` Token: ${mask(creds.token)}\n`);
104
- process.stderr.write(` OpenRouter: ${mask(creds.openRouterKey)}\n`);
105
- process.stderr.write(` Anthropic: ${mask(creds.anthropicKey)}\n`);
106
- const env = process.env.TARANG_ENV || process.env.NODE_ENV || 'production';
107
- process.stderr.write(` Environment: ${DIM}${env}${RESET}\n`);
108
- process.stderr.write(` Backend URL: ${DIM}${creds.backendUrl}${RESET}\n`);
109
- process.stderr.write(` Mode: ${DIM}${creds.mode || 'auto'}${RESET}\n`);
110
- process.stderr.write('\n');
120
+ const check = paint.state.success(icons.pass);
121
+ const cross = paint.state.danger(icons.fail);
122
+ const dim = paint.text.dim;
123
+
124
+ const mask = (val) => {
125
+ if (!val) return `${cross} not set`;
126
+ if (val.length <= 8) return `${check} ****`;
127
+ return `${check} ${val.slice(0, 6)}...${val.slice(-4)}`;
128
+ };
129
+
130
+ const env = process.env.TARANG_ENV || process.env.NODE_ENV || 'production';
131
+
132
+ write(`\n${paint.bold('Kepler Configuration')} ${dim('(~/.kepler/config.json)')}\n`);
133
+ write(`${dim(''.repeat(50))}\n`);
134
+ write(` Token: ${mask(creds.token)}\n`);
135
+ write(` OpenRouter: ${mask(creds.openRouterKey)}\n`);
136
+ write(` Anthropic: ${mask(creds.anthropicKey)}\n`);
137
+ write(` Environment: ${dim(env)}\n`);
138
+ write(` Backend URL: ${dim(creds.backendUrl)}\n`);
139
+ write(` Mode: ${dim(creds.mode || 'auto')}\n`);
140
+ write('\n');
111
141
  }
112
142
 
113
- /**
114
- * Print a goodbye message.
115
- */
116
143
  export function printGoodbye() {
117
- process.stderr.write(`\n${BOLD_CYAN}Goodbye!${RESET}\n\n`);
144
+ write(`\n${paint.bold(paint.brand.primary('Goodbye!'))}\n\n`);
118
145
  }
119
146
 
120
- /**
121
- * Get git branch and change count for current directory.
122
- * @param {string} cwd
123
- * @returns {string|null}
124
- */
125
- function getGitInfo(cwd) {
126
- try {
127
- const branch = execSync('git branch --show-current', {
128
- cwd, encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'],
129
- }).trim();
130
-
131
- if (!branch) return null;
132
-
133
- const status = execSync('git status --porcelain', {
134
- cwd, encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'],
135
- }).trim();
147
+ // ── Git probe ────────────────────────────────────────────────────────────
136
148
 
137
- const changes = status ? status.split('\n').filter(Boolean).length : 0;
138
-
139
- if (changes > 0) {
140
- return `\u2387 ${branch} (${changes} changed)`;
141
- }
142
- return `\u2387 ${branch}`;
143
- } catch {
144
- return null;
145
- }
149
+ function getGitInfo(cwd) {
150
+ try {
151
+ const branch = execSync('git branch --show-current', {
152
+ cwd, encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'],
153
+ }).trim();
154
+ if (!branch) return null;
155
+
156
+ const status = execSync('git status --porcelain', {
157
+ cwd, encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'],
158
+ }).trim();
159
+ const changes = status ? status.split('\n').filter(Boolean).length : 0;
160
+
161
+ return changes > 0 ? `⎇ ${branch} (${changes} changed)` : `⎇ ${branch}`;
162
+ } catch {
163
+ return null;
164
+ }
146
165
  }
147
166
 
148
- /**
149
- * Strip ANSI escape codes for length calculation.
150
- */
151
- function stripAnsi(str) {
152
- return str.replace(/\x1b\[[0-9;]*m/g, '');
153
- }
167
+ // ── OAuth success page (unchanged from prior version) ────────────────────
154
168
 
155
- /**
156
- * Branded HTML for OAuth success page.
157
- */
158
169
  export function getLoginSuccessHTML() {
159
- return `<!DOCTYPE html>
170
+ return `<!DOCTYPE html>
160
171
  <html>
161
172
  <head>
162
173
  <meta charset="utf-8">
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Keyboard dock — Mission Control (PRD-055 §5.4).
3
+ *
4
+ * Maps each orbit to the dock hint shown on the second line of the status
5
+ * bar. Pure data so tests, the status bar, and `/help` stay in sync.
6
+ *
7
+ * import { dockForOrbit } from './dock.mjs';
8
+ * const hints = dockForOrbit(orbit); // → [{ key, label }, ...]
9
+ * const text = renderDock(hints, paint); // → ANSI-styled single line
10
+ */
11
+
12
+ import { ORBITS } from '../state/orbit.mjs';
13
+ import { paint, width } from './palette.mjs';
14
+ import { term } from './term.mjs';
15
+
16
+ const DEFAULT = [
17
+ { key: 'Enter', label: 'send' },
18
+ { key: '/', label: 'commands' },
19
+ { key: '?', label: 'help' },
20
+ ];
21
+
22
+ const HINTS = {
23
+ [ORBITS.IDLE]: DEFAULT,
24
+ [ORBITS.DISCOVERY]: DEFAULT,
25
+
26
+ [ORBITS.PLANNING]: [
27
+ { key: 'p', label: 'pause' },
28
+ { key: 'Esc', label: 'interrupt' },
29
+ { key: '?', label: 'why' },
30
+ ],
31
+
32
+ [ORBITS.EXECUTION]: [
33
+ { key: 'd', label: 'last diff' },
34
+ { key: 'p', label: 'pause' },
35
+ { key: 'Esc', label: 'interrupt' },
36
+ ],
37
+
38
+ [ORBITS.ALIGNMENT]: [
39
+ { key: 'd', label: 'last result' },
40
+ { key: '?', label: 'explain' },
41
+ ],
42
+
43
+ [ORBITS.AWAITING]: [
44
+ { key: 'Enter', label: 'approve' },
45
+ { key: 'e', label: 'edit' },
46
+ { key: 'r', label: 're-plan' },
47
+ { key: 'n', label: 'reject' },
48
+ { key: '?', label: 'why' },
49
+ ],
50
+
51
+ [ORBITS.PAUSED]: [
52
+ { key: 'r', label: 'resume' },
53
+ { key: 'Esc', label: 'cancel' },
54
+ { key: 'q', label: 'quit' },
55
+ ],
56
+ };
57
+
58
+ /**
59
+ * Return the array of hints for the given orbit.
60
+ * Falls back to the default dock for unknown orbits so the bar is never empty.
61
+ */
62
+ export function dockForOrbit(orbit) {
63
+ return HINTS[orbit] || DEFAULT;
64
+ }
65
+
66
+ /**
67
+ * Render a hint list to an ANSI-styled line that fits within `maxWidth`
68
+ * visible characters. Drops trailing entries when they don't fit, never
69
+ * mid-hint truncates.
70
+ */
71
+ export function renderDock(hints, maxWidth = term().columns - 2) {
72
+ if (!Array.isArray(hints) || hints.length === 0) return '';
73
+ const parts = [];
74
+ let used = 0;
75
+ for (const h of hints) {
76
+ const piece = `[${h.key}] ${h.label}`;
77
+ const sep = parts.length ? ' ' : '';
78
+ const cost = width(sep) + width(piece);
79
+ if (used + cost > maxWidth) break;
80
+ parts.push(sep + paintHint(h));
81
+ used += cost;
82
+ }
83
+ return parts.join('');
84
+ }
85
+
86
+ function paintHint(h) {
87
+ return paint.text.dim('[') + paint.brand.data(h.key) + paint.text.dim('] ' + h.label);
88
+ }