@pellux/goodvibes-tui 0.21.0 → 0.23.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +1 -1
  3. package/package.json +2 -1
  4. package/src/cli/completions/generate.ts +4 -8
  5. package/src/cli/entrypoint.ts +6 -0
  6. package/src/cli/management-commands.ts +1 -1
  7. package/src/cli/management-utils.ts +352 -0
  8. package/src/cli/management.ts +36 -334
  9. package/src/cli/parser.ts +17 -0
  10. package/src/cli/surface-command.ts +1 -1
  11. package/src/cli/types.ts +2 -0
  12. package/src/config/goodvibes-home-audit.ts +2 -0
  13. package/src/core/context-auto-compact.ts +110 -0
  14. package/src/core/conversation-rendering.ts +5 -2
  15. package/src/core/conversation-types.ts +24 -0
  16. package/src/core/conversation.ts +7 -12
  17. package/src/core/stream-event-wiring.ts +125 -7
  18. package/src/core/turn-event-wiring.ts +124 -0
  19. package/src/daemon/cli.ts +5 -0
  20. package/src/input/command-registry.ts +1 -0
  21. package/src/input/commands/channel-runtime.ts +139 -0
  22. package/src/input/commands/control-room-runtime.ts +5 -5
  23. package/src/input/commands/provider.ts +57 -3
  24. package/src/input/commands/runtime-services.ts +30 -1
  25. package/src/input/commands/session-workflow.ts +8 -16
  26. package/src/input/commands/session.ts +70 -20
  27. package/src/input/commands/share-runtime.ts +1 -1
  28. package/src/input/commands/shell-core.ts +54 -4
  29. package/src/input/commands.ts +2 -2
  30. package/src/input/handler-modal-routes.ts +37 -0
  31. package/src/input/handler-modal-token-routes.ts +19 -5
  32. package/src/input/handler-onboarding.ts +18 -0
  33. package/src/input/handler.ts +1 -0
  34. package/src/input/onboarding/onboarding-wizard-apply.ts +10 -0
  35. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +14 -0
  36. package/src/input/onboarding/onboarding-wizard-steps.ts +6 -0
  37. package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
  38. package/src/input/settings-modal-behavior.ts +5 -0
  39. package/src/input/settings-modal-data.ts +77 -3
  40. package/src/input/settings-modal-mutations.ts +3 -0
  41. package/src/input/settings-modal-reset.ts +154 -0
  42. package/src/input/settings-modal.ts +55 -13
  43. package/src/main.ts +58 -50
  44. package/src/panels/agent-inspector-panel.ts +120 -18
  45. package/src/panels/agent-inspector-shared.ts +29 -0
  46. package/src/panels/builtin/development.ts +1 -0
  47. package/src/panels/builtin/knowledge.ts +14 -13
  48. package/src/panels/builtin/operations.ts +22 -1
  49. package/src/panels/builtin/shared.ts +7 -0
  50. package/src/panels/cockpit-panel.ts +123 -3
  51. package/src/panels/cockpit-read-model.ts +232 -0
  52. package/src/panels/index.ts +1 -1
  53. package/src/panels/knowledge-graph-panel.ts +84 -0
  54. package/src/panels/memory-panel.ts +370 -40
  55. package/src/panels/session-maintenance.ts +66 -15
  56. package/src/renderer/agent-detail-modal.ts +107 -3
  57. package/src/renderer/compaction-history-modal.ts +55 -0
  58. package/src/renderer/compaction-preview.ts +146 -0
  59. package/src/renderer/context-status-hint.ts +54 -0
  60. package/src/renderer/settings-modal-helpers.ts +2 -2
  61. package/src/renderer/settings-modal.ts +14 -3
  62. package/src/renderer/shell-surface.ts +10 -0
  63. package/src/runtime/bootstrap-command-parts.ts +4 -0
  64. package/src/runtime/bootstrap-core.ts +116 -0
  65. package/src/runtime/bootstrap-shell.ts +11 -0
  66. package/src/runtime/bootstrap.ts +7 -0
  67. package/src/runtime/services.ts +6 -1
  68. package/src/utils/browser.ts +29 -0
  69. package/src/version.ts +1 -1
  70. package/src/panels/knowledge-panel.ts +0 -343
@@ -8,18 +8,25 @@ import { formatDuration } from './modal-utils.ts';
8
8
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
9
9
  import { getOverlaySurfaceMetrics, getStableOverlayContentRows } from './overlay-viewport.ts';
10
10
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
11
+ import { handleConfirmInput, type ConfirmState } from '../panels/confirm-state.ts';
12
+ import { AGENT_TERMINAL_STATUSES as MODAL_TERMINAL_STATUSES, AGENT_STALL_THRESHOLD_MS as MODAL_STALL_THRESHOLD_MS } from '../panels/agent-inspector-shared.ts';
11
13
 
12
14
  // ─── Constants ────────────────────────────────────────────────────────────────
13
15
 
14
16
  const MAX_LOG_ENTRIES = 10;
15
17
  const AGENT_ID_DISPLAY_LENGTH = 16;
16
18
 
19
+ // MODAL_TERMINAL_STATUSES and MODAL_STALL_THRESHOLD_MS are re-exported aliases
20
+ // from agent-inspector-shared.ts (imported above alongside ConfirmState).
21
+
17
22
  export interface AgentDetailModalDeps {
18
- readonly agentManager: Pick<AgentManager, 'getStatus'>;
23
+ readonly agentManager: Pick<AgentManager, 'getStatus' | 'list'>;
19
24
  readonly agentMessageBus: Pick<AgentMessageBus, 'getMessages'>;
20
25
  readonly sessionLogPathResolver: (agentId: string) => string;
21
26
  /** Optional — when supplied, constraint data from the agent's WRFC chain is shown (SDK 0.23.0). */
22
27
  readonly wrfcController?: Pick<WrfcController, 'getChain'>;
28
+ /** Cancel the agent by id using the same orphan-free path as WRFC. Returns true if cancelled. */
29
+ readonly cancelAgent: (agentId: string) => boolean;
23
30
  }
24
31
 
25
32
  // ─── AgentDetailModal ─────────────────────────────────────────────────────────
@@ -39,6 +46,9 @@ export class AgentDetailModal {
39
46
  public logEntries: Record<string, unknown>[] = [];
40
47
  public logTotal = 0;
41
48
 
49
+ /** Pending cancel confirmation. Subject is the agent id to cancel. */
50
+ public confirmCancel: ConfirmState<string> | null = null;
51
+
42
52
  private refreshTimer: ReturnType<typeof setInterval> | null = null;
43
53
  private onRefresh: (() => void) | null = null;
44
54
 
@@ -67,12 +77,88 @@ export class AgentDetailModal {
67
77
  this.agentId = null;
68
78
  this.logEntries = [];
69
79
  this.logTotal = 0;
80
+ this.confirmCancel = null;
70
81
  if (this.refreshTimer) {
71
82
  clearInterval(this.refreshTimer);
72
83
  this.refreshTimer = null;
73
84
  }
74
85
  }
75
86
 
87
+ /**
88
+ * Handle a key press while the modal is active.
89
+ * Must be called BEFORE the Esc handler closes the modal.
90
+ *
91
+ * Routes:
92
+ * - 'c' initiates cancel confirm (if agent is non-terminal)
93
+ * - confirm keys (Enter/y/n/Esc) are forwarded to handleConfirmInput
94
+ *
95
+ * Returns true when the key was consumed (caller should NOT propagate).
96
+ */
97
+ handleKey(key: string): boolean {
98
+ if (!this.active) return false;
99
+
100
+ if (this.confirmCancel) {
101
+ const result = handleConfirmInput(this.confirmCancel, key);
102
+ if (result === 'confirmed') {
103
+ if (this.agentId) {
104
+ const rec = this.deps.agentManager.getStatus(this.agentId);
105
+ if (rec && !MODAL_TERMINAL_STATUSES.has(rec.status)) {
106
+ this.deps.cancelAgent(rec.id);
107
+ }
108
+ }
109
+ this.confirmCancel = null;
110
+ this.onRefresh?.();
111
+ return true;
112
+ }
113
+ if (result === 'cancelled') {
114
+ this.confirmCancel = null;
115
+ this.onRefresh?.();
116
+ return true;
117
+ }
118
+ // absorbed — key swallowed while confirm is pending
119
+ return true;
120
+ }
121
+
122
+ if (key === 'c') {
123
+ if (this.agentId) {
124
+ const rec = this.deps.agentManager.getStatus(this.agentId);
125
+ if (rec && !MODAL_TERMINAL_STATUSES.has(rec.status)) {
126
+ const label = rec.task.split('\n')[0]?.slice(0, 40) ?? rec.id.slice(-8);
127
+ this.confirmCancel = { subject: rec.id, label };
128
+ this.onRefresh?.();
129
+ return true;
130
+ }
131
+ }
132
+ // Non-cancellable — absorb key silently
133
+ return true;
134
+ }
135
+
136
+ return false;
137
+ }
138
+
139
+ /**
140
+ * Returns whether the current agent is considered stalled.
141
+ * Non-terminal agent with elapsed time exceeding MODAL_STALL_THRESHOLD_MS.
142
+ */
143
+ isCurrentAgentStalled(): boolean {
144
+ if (!this.agentId) return false;
145
+ const rec = this.deps.agentManager.getStatus(this.agentId);
146
+ if (!rec || MODAL_TERMINAL_STATUSES.has(rec.status)) return false;
147
+ return (Date.now() - rec.startedAt) >= MODAL_STALL_THRESHOLD_MS;
148
+ }
149
+
150
+ /**
151
+ * Count of all stalled agents across the agentManager list.
152
+ * Non-terminal agents with elapsed time >= MODAL_STALL_THRESHOLD_MS.
153
+ */
154
+ getStalledAgentCount(): number {
155
+ const now = Date.now();
156
+ return this.deps.agentManager.list().filter(rec => {
157
+ if (MODAL_TERMINAL_STATUSES.has(rec.status)) return false;
158
+ return (now - rec.startedAt) >= MODAL_STALL_THRESHOLD_MS;
159
+ }).length;
160
+ }
161
+
76
162
  async loadLog(): Promise<void> {
77
163
  if (!this.agentId) { this.logEntries = []; this.logTotal = 0; return; }
78
164
  try {
@@ -161,7 +247,8 @@ export function renderAgentDetailModal(
161
247
  const modelStr = rec.model ? `${rec.provider ?? ''}/${rec.model}` : (rec.provider ?? '(default)');
162
248
  sections.push({ type: 'text', content: `Template : ${rec.template}` });
163
249
  sections.push({ type: 'text', content: `Model : ${modelStr}` });
164
- sections.push({ type: 'text', content: `Status : ${rec.status}` });
250
+ const isStalled = !MODAL_TERMINAL_STATUSES.has(rec.status) && (now - rec.startedAt) >= MODAL_STALL_THRESHOLD_MS;
251
+ sections.push({ type: 'text', content: `Status : ${rec.status}${isStalled ? ' [STALLED — 5+ min no activity]' : ''}` });
165
252
  sections.push({ type: 'text', content: `Duration : ${formatDuration(elapsedMs)}` });
166
253
  sections.push({ type: 'separator' });
167
254
 
@@ -321,12 +408,29 @@ export function renderAgentDetailModal(
321
408
  }
322
409
  }
323
410
 
411
+ // Cancel confirm overlay (when pending)
412
+ const cancellable = !MODAL_TERMINAL_STATUSES.has(rec.status);
413
+ if (modal.confirmCancel) {
414
+ sections.push({ type: 'separator' });
415
+ sections.push({
416
+ type: 'text',
417
+ content: `Cancel agent "${modal.confirmCancel.label}"?`,
418
+ style: { fg: '#f59e0b' },
419
+ });
420
+ sections.push({
421
+ type: 'text',
422
+ content: 'y / Enter confirm n / Esc cancel',
423
+ style: { dim: true },
424
+ });
425
+ }
426
+
427
+ const cancelHint = cancellable ? '[c] Cancel ' : '';
324
428
  return ModalFactory.createModal({
325
429
  title: `Agent: ${rec.id.slice(0, AGENT_ID_DISPLAY_LENGTH)}`,
326
430
  width: metrics.boxWidth,
327
431
  margin: metrics.margin,
328
432
  targetContentRows,
329
433
  sections,
330
- hints: ['[Esc] Close'],
434
+ hints: [cancelHint + '[Esc] Close'],
331
435
  }, width);
332
436
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Compaction history text builder.
3
+ *
4
+ * Renders a read-only list of past compaction events sourced from the SDK's
5
+ * module-level compaction event log (`getCompactionEvents()`).
6
+ *
7
+ * The SDK records CompactionEvent data (timestamps, token counts,
8
+ * trigger, message counts) but does not expose a snapshot restore API.
9
+ * Restore is list-only; users can view what compactions ran but cannot roll back.
10
+ */
11
+
12
+ import { getCompactionEvents } from '@pellux/goodvibes-sdk/platform/core';
13
+ import type { CompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
14
+
15
+ // ─── formatCompactionEvent ────────────────────────────────────────────────────
16
+
17
+ function formatCompactionEvent(ev: CompactionEvent, n: number): string {
18
+ const date = new Date(ev.timestamp);
19
+ const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
20
+ const savings = Math.max(0, ev.tokensBeforeEstimate - ev.tokensAfterEstimate);
21
+ const savingsPct = ev.tokensBeforeEstimate > 0
22
+ ? Math.round((savings / ev.tokensBeforeEstimate) * 100)
23
+ : 0;
24
+ const trigger = ev.trigger === 'auto' ? 'auto' : 'manual';
25
+ return (
26
+ `#${n} ${timeStr} [${trigger}] ` +
27
+ `${ev.messagesBeforeCompaction}→${ev.messagesAfterCompaction} msgs ` +
28
+ `~${fmtN(ev.tokensBeforeEstimate)}→~${fmtN(ev.tokensAfterEstimate)} tok ` +
29
+ `saved ${savingsPct}%`
30
+ );
31
+ }
32
+
33
+ function fmtN(n: number): string {
34
+ return n.toLocaleString();
35
+ }
36
+
37
+ /**
38
+ * Build a plain-text compaction history summary suitable for ctx.print().
39
+ * Useful as the output of /compact-history when not in overlay mode.
40
+ */
41
+ export function buildCompactionHistoryText(): string {
42
+ const events = getCompactionEvents();
43
+ if (events.length === 0) {
44
+ return '[Context] No compactions recorded this session. (Restore is not available — the SDK does not yet expose a snapshot restore API.)';
45
+ }
46
+ const lines: string[] = [
47
+ `[Context] Compaction history (${events.length} total, most recent first):`,
48
+ ];
49
+ const ordered = [...events].reverse();
50
+ for (let i = 0; i < ordered.length; i++) {
51
+ lines.push(' ' + formatCompactionEvent(ordered[i], ordered.length - i));
52
+ }
53
+ lines.push(' (Restore not available — the SDK does not yet expose a snapshot restore API.)');
54
+ return lines.join('\n');
55
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Compaction preview and after-notice builders.
3
+ *
4
+ * Pre-compact: shows an honest estimate of what compaction will do
5
+ * (message count and token estimate before → after). The SDK has no dry-run
6
+ * API; we derive the after-estimate from the DEFAULT_COMPACTION_CONFIG
7
+ * totalCeiling (6500 tokens) — clearly labelled as an ESTIMATE.
8
+ *
9
+ * Post-compact: shows a before/after notice using the CompactionEvent
10
+ * data returned by compactMessages(), which contains the real
11
+ * tokensBeforeEstimate and tokensAfterEstimate figures.
12
+ *
13
+ * Honest wording policy:
14
+ * - Pre-compact notice says "estimate" every time; never claims certainty.
15
+ * - Post-compact notice uses "~N" prefix on every token figure.
16
+ * - Pinned session memories that survive are mentioned by count.
17
+ */
18
+
19
+ import { estimateConversationTokens } from '@pellux/goodvibes-sdk/platform/core';
20
+ import type { CompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
21
+ import type { ProviderMessage } from '@pellux/goodvibes-sdk/platform/providers';
22
+
23
+ /**
24
+ * Default compaction totalCeiling from context-compaction DEFAULT_COMPACTION_CONFIG.
25
+ * Kept local so we don't take a runtime dependency on the internal config object.
26
+ * Used only for the pre-compact ESTIMATE; the real figure comes from CompactionEvent.
27
+ */
28
+ const COMPACTION_OUTPUT_CEILING_ESTIMATE = 6500;
29
+
30
+ export interface CompactionPreviewOptions {
31
+ /** Messages currently in the conversation. */
32
+ readonly messages: readonly ProviderMessage[];
33
+ /** Context window size for the current model (0 if unknown). */
34
+ readonly contextWindow: number;
35
+ /** Number of session memories that will survive compaction. */
36
+ readonly pinnedMemoryCount: number;
37
+ /** Whether this is triggered automatically or manually. */
38
+ readonly trigger: 'auto' | 'manual';
39
+ }
40
+
41
+ export interface CompactionAfterOptions {
42
+ /** The CompactionEvent returned by the SDK after compaction completes. */
43
+ readonly event: CompactionEvent;
44
+ /** Number of session memories that survived compaction. */
45
+ readonly pinnedMemoryCount: number;
46
+ }
47
+
48
+ /**
49
+ * Build the pre-compaction notice string.
50
+ *
51
+ * Returned as a plain string intended for `ctx.print()` or `systemMessageRouter`.
52
+ * Always labelled as an estimate; uses the SDK totalCeiling as the after-estimate.
53
+ */
54
+ export function buildCompactionPreview(opts: CompactionPreviewOptions): string {
55
+ const { messages, contextWindow, pinnedMemoryCount, trigger } = opts;
56
+ const msgCount = messages.length;
57
+ const tokensBefore = estimateConversationTokens(messages as ProviderMessage[]);
58
+ const tokensAfterEstimate = COMPACTION_OUTPUT_CEILING_ESTIMATE;
59
+
60
+ const contextStr = contextWindow > 0
61
+ ? ` (${Math.round((tokensBefore / contextWindow) * 100)}% of ${fmtN(contextWindow)} context window)`
62
+ : '';
63
+
64
+ const pinStr = pinnedMemoryCount > 0
65
+ ? ` ${pinnedMemoryCount} pinned session memor${pinnedMemoryCount === 1 ? 'y' : 'ies'} will be preserved.`
66
+ : '';
67
+
68
+ const triggerStr = trigger === 'auto' ? 'Auto-compacting' : 'Compacting';
69
+
70
+ return (
71
+ `[Context] ${triggerStr} conversation: ~${fmtN(tokensBefore)} tokens across ${msgCount} message${msgCount === 1 ? '' : 's'}${contextStr}.` +
72
+ ` Estimated result: ~${fmtN(tokensAfterEstimate)} tokens (estimate — actual depends on content).` +
73
+ (pinStr ? ` ${pinStr.trim()}` : '')
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Build the post-compaction before/after notice string.
79
+ *
80
+ * Uses the real CompactionEvent figures (not estimates) for both before and
81
+ * after token counts. The trigger field controls wording.
82
+ */
83
+ export function buildCompactionAfterNotice(opts: CompactionAfterOptions): string {
84
+ const { event, pinnedMemoryCount } = opts;
85
+ const {
86
+ messagesBeforeCompaction,
87
+ messagesAfterCompaction,
88
+ tokensBeforeEstimate,
89
+ tokensAfterEstimate,
90
+ trigger,
91
+ } = event;
92
+
93
+ const savings = Math.max(0, tokensBeforeEstimate - tokensAfterEstimate);
94
+ const savingsPct = tokensBeforeEstimate > 0
95
+ ? Math.round((savings / tokensBeforeEstimate) * 100)
96
+ : 0;
97
+
98
+ const pinStr = pinnedMemoryCount > 0
99
+ ? ` ${pinnedMemoryCount} pinned memor${pinnedMemoryCount === 1 ? 'y' : 'ies'} preserved.`
100
+ : '';
101
+
102
+ const triggerStr = trigger === 'auto' ? 'Auto-compact complete' : 'Compact complete';
103
+
104
+ return (
105
+ `[Context] ${triggerStr}: ${messagesBeforeCompaction} → ${messagesAfterCompaction} messages,` +
106
+ ` ~${fmtN(tokensBeforeEstimate)} → ~${fmtN(tokensAfterEstimate)} tokens` +
107
+ ` (saved ~${fmtN(savings)}, ${savingsPct}%).` +
108
+ (pinStr ? ` ${pinStr.trim()}` : '')
109
+ );
110
+ }
111
+
112
+ /** Format a number with thousands separators. */
113
+ function fmtN(n: number): string {
114
+ return n.toLocaleString();
115
+ }
116
+
117
+ /**
118
+ * Build the /keep command usage text (shown when no args are provided).
119
+ *
120
+ * Exported for testability — the shell-core handler renders this string directly.
121
+ */
122
+ export function buildPinUsageText(): string {
123
+ return (
124
+ '[Pin] Usage: /keep <text>\n' +
125
+ 'Pinned entries are stored as session memories and included in the compaction handoff as pinned memories.\n' +
126
+ 'What pinning guarantees: the text survives the next compaction.\n' +
127
+ 'What pinning does NOT guarantee: recovery after process restart (session memories are in-memory only).'
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Build the /keep command success text.
133
+ *
134
+ * @param id - The assigned memory ID (e.g. "mem-1")
135
+ * @param text - The pinned text
136
+ * @param count - Total pinned memory count after adding
137
+ *
138
+ * Exported for testability — the shell-core handler renders this string directly.
139
+ */
140
+ export function buildPinSuccessText(id: string, text: string, count: number): string {
141
+ return (
142
+ `[Pin] Pinned as ${id}: "${text.slice(0, 60)}${text.length > 60 ? '...' : ''}"\n` +
143
+ ` ${count} pinned memor${count === 1 ? 'y' : 'ies'} will survive the next compaction.\n` +
144
+ ' Note: session memories are in-memory only and do not persist across restarts.'
145
+ );
146
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Context status hint — TASK-056.
3
+ *
4
+ * Produces a short, dismissible status-line hint when the session maintenance
5
+ * level indicates compaction is recommended or repair is needed. The hint is
6
+ * passive and non-blocking: it appears in the footer status row and disappears
7
+ * once the pressure signal clears.
8
+ *
9
+ * Honest wording policy:
10
+ * - suggest-compact → describes the situation and offers /compact
11
+ * - needs-repair → names the failure state honestly without alarming
12
+ * - compacting → shows in-progress text so the user knows something is running
13
+ * - watch → no hint (not yet actionable)
14
+ * - stable/unknown → no hint
15
+ */
16
+
17
+ import type { PanelSessionMaintenanceLevel } from '../panels/session-maintenance.ts';
18
+
19
+ export interface ContextStatusHintOptions {
20
+ /** Maintenance level from evaluateSessionMaintenance. */
21
+ readonly level: PanelSessionMaintenanceLevel;
22
+ /** Whether auto-compaction is active (threshold > 0 in config). */
23
+ readonly autoCompactEnabled: boolean;
24
+ /** Current usage percent 0–100. */
25
+ readonly usagePct: number;
26
+ }
27
+
28
+ /**
29
+ * Build the passive status-line hint text for context pressure.
30
+ *
31
+ * Returns null when no hint is warranted (stable / watch / unknown).
32
+ * The caller renders this as a dim informational line — no prompts, no
33
+ * blocking, no confirmation required.
34
+ */
35
+ export function buildContextStatusHint(options: ContextStatusHintOptions): string | null {
36
+ const { level, autoCompactEnabled, usagePct } = options;
37
+
38
+ switch (level) {
39
+ case 'needs-repair':
40
+ return ` Context pressure critical (${usagePct}% used) — compaction needs attention. Run /compact or /health review.`;
41
+
42
+ case 'suggest-compact':
43
+ if (autoCompactEnabled) {
44
+ return ` Context high (${usagePct}% used) — auto-compact will run before the next turn.`;
45
+ }
46
+ return ` Context high (${usagePct}% used) — run /compact to recover headroom.`;
47
+
48
+ case 'compacting':
49
+ return ` Compacting context — freeing headroom...`;
50
+
51
+ default:
52
+ return null;
53
+ }
54
+ }
@@ -4,8 +4,8 @@
4
4
  * architecture cap. No layout logic lives here.
5
5
  */
6
6
 
7
- import type { SettingEntry, McpEntry, SubscriptionEntry } from '../input/settings-modal.ts';
8
- import { SETTINGS_CATEGORIES } from '../input/settings-modal.ts';
7
+ import type { SettingEntry, McpEntry, SubscriptionEntry } from '../input/settings-modal-types.ts';
8
+ import { SETTINGS_CATEGORIES } from '../input/settings-modal-types.ts';
9
9
  import { isSecretConfigKey, isSecretReferenceValue } from '../config/secret-config.ts';
10
10
 
11
11
  function maskSecretValue(value: string): string {
@@ -539,14 +539,25 @@ function rowColorForSetting(modal: SettingsModal, rowText: string): string {
539
539
  return valueColor(selected);
540
540
  }
541
541
 
542
- function footerText(modal: SettingsModal): string {
542
+ function footerText(modal: SettingsModal, width: number): string {
543
+ // Armed reset gate takes priority over all other footer states.
544
+ if (modal.resetCategoryConfirm !== null || modal.resetAllConfirm !== null)
545
+ return 'Reset armed · Enter/y confirm · Esc/n cancel';
543
546
  if (modal.searchFocused) return 'Search · type to filter · Up/Down navigate results · Enter select · Esc exit search';
544
547
  if (modal.editingMode) return 'Enter Confirm edit · Esc Cancel edit · text keys edit the selected field';
545
548
  if (modal.focusPane === 'categories') return 'Focus categories · Up/Down choose · Right/Enter settings · Tab pane · / search · Esc close';
546
549
  if (modal.currentCategory === 'subscriptions') return 'Focus settings · Up/Down provider · Left categories · Tab pane · / search · Enter review/sign out · Esc close';
547
550
  if (modal.currentCategory === 'mcp') return 'Focus settings · Up/Down server · Left categories · Tab pane · / search · Enter edit trust · Esc close';
548
551
  if (modal.currentCategory === 'flags') return 'Focus feature flags · Up/Down flag · Left categories · Tab pane · / search · Enter/Space toggle · Esc close';
549
- return 'Focus settings · Up/Down setting · Left categories · Tab pane · / search · Enter/Space edit/toggle · R reset · Esc close';
552
+ // Default settings pane: tier the reset affordances by available width.
553
+ // W<80: minimal — only the most critical action survives.
554
+ // W<160: compact but still shows both reset affordances.
555
+ // W≥160: standard with all navigation tokens.
556
+ if (width < 80)
557
+ return 'R reset · Esc';
558
+ if (width < 160)
559
+ return 'Up/Down · Enter/Space edit · ⇧R reset cat · ^⇧R reset all · Esc';
560
+ return 'Focus settings · Up/Down setting · Left · Enter/Space edit/toggle · ⇧R reset cat · ^⇧R reset all · Esc close';
550
561
  }
551
562
 
552
563
  export function renderSettingsModal(
@@ -601,6 +612,6 @@ export function renderSettingsModal(
601
612
  })),
602
613
  contextRows,
603
614
  controlRows,
604
- footer: footerText(modal),
615
+ footer: footerText(modal, width),
605
616
  });
606
617
  }
@@ -29,6 +29,11 @@ export interface ShellFooterBuildOptions {
29
29
  readonly composerStatus?: string;
30
30
  readonly composerFlags?: readonly string[];
31
31
  readonly composerPendingRisk?: 'none' | 'approval-wait' | 'shell' | 'command' | 'remote';
32
+ /**
33
+ * Passive context pressure hint from buildContextStatusHint.
34
+ * Rendered as a dim informational line above the prompt when non-null.
35
+ */
36
+ readonly contextStatusHint?: string | null;
32
37
  }
33
38
 
34
39
  export interface ShellFooterBuildResult {
@@ -84,5 +89,10 @@ export function buildShellFooter(
84
89
  );
85
90
  const inputBoxRows = Math.max(1, options.promptLineCount) + 2;
86
91
  lines.splice(inputBoxRows, 0, ...processIndicator);
92
+ // Passive context status hint — rendered as a dim informational line before the prompt.
93
+ if (options.contextStatusHint) {
94
+ const hintLine = UIFactory.stringToLine(options.contextStatusHint, options.width, { fg: '#64748b' });
95
+ lines.unshift(hintLine);
96
+ }
87
97
  return { lines, height: lines.length };
88
98
  }
@@ -160,6 +160,7 @@ export function createBootstrapCommandActions(
160
160
  | 'openMcpWorkspace'
161
161
  | 'openSecurityPanel'
162
162
  | 'openKnowledgePanel'
163
+ | 'openMemoryPanel'
163
164
  | 'openRemotePanel'
164
165
  | 'openSubscriptionPanel'
165
166
  | 'openLocalAuthMaskedEntry'
@@ -273,6 +274,9 @@ export function createBootstrapCommandActions(
273
274
  openKnowledgePanel: () => {
274
275
  showPanel('knowledge');
275
276
  },
277
+ openMemoryPanel: () => {
278
+ showPanel('memory');
279
+ },
276
280
  openRemotePanel: () => {
277
281
  showPanel('remote');
278
282
  },
@@ -123,6 +123,88 @@ export interface BootstrapCoreState {
123
123
 
124
124
  export type CompanionMessagePayload = Extract<SessionEvent, { type: 'COMPANION_MESSAGE_RECEIVED' }>;
125
125
 
126
+ // ---------------------------------------------------------------------------
127
+ // Operator narration of inbound channel events
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Narrate an inbound channel event to the operator via the SystemMessageRouter.
132
+ *
133
+ * When an external surface (GitHub, Slack, ntfy, etc.) triggers an agent turn,
134
+ * this function produces a human-readable system message so the operator can
135
+ * observe which event caused the turn. Returns null for internal/companion
136
+ * sources that do not need operator narration.
137
+ *
138
+ * @param event - The normalized inbound event descriptor.
139
+ * @returns A narration string, or null if no narration is appropriate.
140
+ */
141
+ export function narrateInboundEvent(event: {
142
+ source: string;
143
+ metadata: Readonly<Record<string, unknown>> | undefined;
144
+ }): string | null {
145
+ const { source, metadata } = event;
146
+ if (!source) return null;
147
+
148
+ // Derive the effective surface — prefer metadata.surface, fall back to source.
149
+ const surface = typeof metadata?.surface === 'string' ? metadata.surface : source;
150
+
151
+ // Internal / companion sources do not need operator narration.
152
+ if (surface === 'companion' || source === 'companion') return null;
153
+ if (surface === 'internal' || source === 'internal') return null;
154
+
155
+ // Build a surface label for the log prefix.
156
+ const label = ((): string => {
157
+ switch (surface) {
158
+ case 'github': return '[GitHub]';
159
+ case 'slack': return '[Slack]';
160
+ case 'discord': return '[Discord]';
161
+ case 'ntfy': return '[ntfy]';
162
+ case 'homeassistant': return '[HomeAssistant]';
163
+ case 'telegram': return '[Telegram]';
164
+ case 'google-chat': return '[Google Chat]';
165
+ case 'signal': return '[Signal]';
166
+ case 'whatsapp': return '[WhatsApp]';
167
+ case 'msteams': return '[Teams]';
168
+ case 'imessage': return '[iMessage]';
169
+ case 'bluebubbles': return '[BlueBubbles]';
170
+ case 'mattermost': return '[Mattermost]';
171
+ case 'matrix': return '[Matrix]';
172
+ case 'webhook': return '[Webhook]';
173
+ default: return `[${surface[0]!.toUpperCase()}${surface.slice(1)}]`;
174
+ }
175
+ })();
176
+
177
+ const eventType = typeof metadata?.eventType === 'string' ? metadata.eventType : null;
178
+ const eventAction = typeof metadata?.eventAction === 'string' ? metadata.eventAction : null;
179
+ const topic = typeof metadata?.topic === 'string' ? metadata.topic : null;
180
+ const prNumber = typeof metadata?.prNumber === 'number' ? metadata.prNumber : null;
181
+ const issueNumber = typeof metadata?.issueNumber === 'number' ? metadata.issueNumber : null;
182
+ const repo = typeof metadata?.repo === 'string' ? metadata.repo : null;
183
+
184
+ // Build event-specific detail for GitHub events.
185
+ if (surface === 'github' && eventType) {
186
+ const actionPart = eventAction ? ` ${eventAction}` : '';
187
+ let detail = `${eventType}${actionPart} → agent triggered`;
188
+ if (prNumber !== null) {
189
+ detail = `PR #${prNumber}${repo ? ` (${repo})` : ''} ${eventAction ?? eventType} → agent triggered`;
190
+ } else if (issueNumber !== null) {
191
+ detail = `Issue #${issueNumber}${repo ? ` (${repo})` : ''} ${eventAction ?? eventType} → agent triggered`;
192
+ } else if (repo) {
193
+ detail = `${eventType}${actionPart} in ${repo} → agent triggered`;
194
+ }
195
+ return `${label} ${detail}`;
196
+ }
197
+
198
+ // ntfy: include topic when available.
199
+ if (surface === 'ntfy' && topic) {
200
+ return `${label} inbound message on topic '${topic}' → agent triggered`;
201
+ }
202
+
203
+ // Generic narration for all other surfaces.
204
+ const eventDetail = eventType ? ` ${eventType}${eventAction ? ` ${eventAction}` : ''}` : '';
205
+ return `${label}${eventDetail} inbound event → agent triggered`;
206
+ }
207
+
126
208
  export function companionMessageToOrchestratorInputOptions(
127
209
  payload: CompanionMessagePayload,
128
210
  ): OrchestratorUserInputOptions {
@@ -426,6 +508,30 @@ export async function initializeBootstrapCore(
426
508
  }
427
509
  };
428
510
 
511
+ // Startup TLS banner — emitted via wrfcBuffer.push() because the
512
+ // SystemMessageRouter is not attached yet at this point in bootstrap. The
513
+ // smart-ref setter on systemMessageRouterRef auto-flushes the buffer when
514
+ // the router attaches, so the message will appear in the WRFC panel on startup.
515
+ {
516
+ const cpEnabled = Boolean(configManager.get('controlPlane.enabled'));
517
+ const cpHostMode = String(configManager.get('controlPlane.hostMode') ?? 'local');
518
+ const cpTlsMode = String(configManager.get('controlPlane.tls.mode') ?? 'off');
519
+ const hlEnabled = Boolean(configManager.get('danger.httpListener'));
520
+ const hlHostMode = String(configManager.get('httpListener.hostMode') ?? 'local');
521
+ const hlTlsMode = String(configManager.get('httpListener.tls.mode') ?? 'off');
522
+ const cpNetworkPlaintext = cpEnabled && cpHostMode !== 'local' && cpTlsMode === 'off';
523
+ const hlNetworkPlaintext = hlEnabled && hlHostMode !== 'local' && hlTlsMode === 'off';
524
+ if (cpNetworkPlaintext || hlNetworkPlaintext) {
525
+ const affected: string[] = [];
526
+ if (cpNetworkPlaintext) affected.push('control plane');
527
+ if (hlNetworkPlaintext) affected.push('HTTP listener');
528
+ wrfcBuffer.push(
529
+ `[SECURITY] TLS is off for the ${affected.join(' and ')} but it is network-reachable. All traffic (credentials, tokens, conversation content) travels in plaintext. Enable TLS (controlPlane.tls.mode / httpListener.tls.mode) or restrict to loopback before exposing to untrusted networks.`,
530
+ 'high',
531
+ );
532
+ }
533
+ }
534
+
429
535
  runtimeUnsubs.push(
430
536
  runtimeBus.on<Extract<import('@/runtime/index.ts').WorkflowEvent, { type: 'WORKFLOW_CONSTRAINTS_ENUMERATED' }>>(
431
537
  'WORKFLOW_CONSTRAINTS_ENUMERATED',
@@ -501,6 +607,16 @@ export async function initializeBootstrapCore(
501
607
  runtimeUnsubs.push(runtimeBus.on<Extract<SessionEvent, { type: 'COMPANION_MESSAGE_RECEIVED' }>>(
502
608
  'COMPANION_MESSAGE_RECEIVED',
503
609
  ({ payload }) => {
610
+ // Narrate inbound external events to the operator so they can observe
611
+ // which channel event triggered the agent turn.
612
+ const narration = narrateInboundEvent({
613
+ source: payload.source,
614
+ metadata: payload.metadata,
615
+ });
616
+ if (narration) {
617
+ routeOrBuffer(narration, 'low');
618
+ }
619
+
504
620
  if (orchestratorHandleUserInputRef.value) {
505
621
  // Delegate to the orchestrator: adds user message + fires a real LLM turn.
506
622
  // Preserve surface origin metadata so the SDK can correlate replies back