@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.
- package/CHANGELOG.md +45 -0
- package/README.md +1 -1
- package/package.json +2 -1
- package/src/cli/completions/generate.ts +4 -8
- package/src/cli/entrypoint.ts +6 -0
- package/src/cli/management-commands.ts +1 -1
- package/src/cli/management-utils.ts +352 -0
- package/src/cli/management.ts +36 -334
- package/src/cli/parser.ts +17 -0
- package/src/cli/surface-command.ts +1 -1
- package/src/cli/types.ts +2 -0
- package/src/config/goodvibes-home-audit.ts +2 -0
- package/src/core/context-auto-compact.ts +110 -0
- package/src/core/conversation-rendering.ts +5 -2
- package/src/core/conversation-types.ts +24 -0
- package/src/core/conversation.ts +7 -12
- package/src/core/stream-event-wiring.ts +125 -7
- package/src/core/turn-event-wiring.ts +124 -0
- package/src/daemon/cli.ts +5 -0
- package/src/input/command-registry.ts +1 -0
- package/src/input/commands/channel-runtime.ts +139 -0
- package/src/input/commands/control-room-runtime.ts +5 -5
- package/src/input/commands/provider.ts +57 -3
- package/src/input/commands/runtime-services.ts +30 -1
- package/src/input/commands/session-workflow.ts +8 -16
- package/src/input/commands/session.ts +70 -20
- package/src/input/commands/share-runtime.ts +1 -1
- package/src/input/commands/shell-core.ts +54 -4
- package/src/input/commands.ts +2 -2
- package/src/input/handler-modal-routes.ts +37 -0
- package/src/input/handler-modal-token-routes.ts +19 -5
- package/src/input/handler-onboarding.ts +18 -0
- package/src/input/handler.ts +1 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +10 -0
- package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +14 -0
- package/src/input/onboarding/onboarding-wizard-steps.ts +6 -0
- package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
- package/src/input/settings-modal-behavior.ts +5 -0
- package/src/input/settings-modal-data.ts +77 -3
- package/src/input/settings-modal-mutations.ts +3 -0
- package/src/input/settings-modal-reset.ts +154 -0
- package/src/input/settings-modal.ts +55 -13
- package/src/main.ts +58 -50
- package/src/panels/agent-inspector-panel.ts +120 -18
- package/src/panels/agent-inspector-shared.ts +29 -0
- package/src/panels/builtin/development.ts +1 -0
- package/src/panels/builtin/knowledge.ts +14 -13
- package/src/panels/builtin/operations.ts +22 -1
- package/src/panels/builtin/shared.ts +7 -0
- package/src/panels/cockpit-panel.ts +123 -3
- package/src/panels/cockpit-read-model.ts +232 -0
- package/src/panels/index.ts +1 -1
- package/src/panels/knowledge-graph-panel.ts +84 -0
- package/src/panels/memory-panel.ts +370 -40
- package/src/panels/session-maintenance.ts +66 -15
- package/src/renderer/agent-detail-modal.ts +107 -3
- package/src/renderer/compaction-history-modal.ts +55 -0
- package/src/renderer/compaction-preview.ts +146 -0
- package/src/renderer/context-status-hint.ts +54 -0
- package/src/renderer/settings-modal-helpers.ts +2 -2
- package/src/renderer/settings-modal.ts +14 -3
- package/src/renderer/shell-surface.ts +10 -0
- package/src/runtime/bootstrap-command-parts.ts +4 -0
- package/src/runtime/bootstrap-core.ts +116 -0
- package/src/runtime/bootstrap-shell.ts +11 -0
- package/src/runtime/bootstrap.ts +7 -0
- package/src/runtime/services.ts +6 -1
- package/src/utils/browser.ts +29 -0
- package/src/version.ts +1 -1
- package/src/panels/knowledge-panel.ts +0 -343
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
|
|
3
|
-
import type { CommandContext
|
|
3
|
+
import type { CommandContext } from '../command-registry.ts';
|
|
4
4
|
import { type SessionMeta } from '@pellux/goodvibes-sdk/platform/sessions';
|
|
5
5
|
import type { TranscriptEventKind } from '@pellux/goodvibes-sdk/platform/core';
|
|
6
6
|
import type { ConversationTitleSource } from '../../core/conversation';
|
|
@@ -441,21 +441,13 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
|
|
|
441
441
|
return false;
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
async handler(args, ctx) {
|
|
452
|
-
const handled = await handleSessionWorkflowCommand(args, ctx);
|
|
453
|
-
if (!handled) {
|
|
454
|
-
ctx.print('Unknown subcommand: ' + (args[0] ?? '') + '\nUsage: /session-mgmt [list | rename <name> | resume <id> | fork [name] | save [name] | info [id] | events [kind] | groups [kind] | hotspots | export <id> [format] | search <query> | delete <id>]');
|
|
455
|
-
}
|
|
456
|
-
},
|
|
457
|
-
});
|
|
458
|
-
}
|
|
444
|
+
// session-mgmt / smgmt was removed in TASK-032.
|
|
445
|
+
// All session lifecycle operations are now first-class subcommands of /session.
|
|
446
|
+
// Use /session list, /session resume, /session save, etc.
|
|
447
|
+
//
|
|
448
|
+
// CommandRegistry.register() throws on duplicate names/aliases, so this
|
|
449
|
+
// registration was intentionally deleted rather than left as dead code.
|
|
450
|
+
|
|
459
451
|
interface SessionExportData {
|
|
460
452
|
readonly messages: object[];
|
|
461
453
|
readonly timestamp?: number;
|
|
@@ -319,18 +319,52 @@ function handleCancel(args: string[], context: CommandContext): void {
|
|
|
319
319
|
/**
|
|
320
320
|
* sessionCommand — The `/session` slash command.
|
|
321
321
|
*
|
|
322
|
-
*
|
|
322
|
+
* The ONE front-door for all session operations. Owns two domains:
|
|
323
|
+
*
|
|
324
|
+
* Lifecycle (continuity, export, resume, pruning):
|
|
325
|
+
* list | rename | resume | fork | save | info | events | groups | hotspots | export | search | delete
|
|
326
|
+
*
|
|
327
|
+
* Orchestration (cross-session task DAG — 40 tests, cycle detection):
|
|
328
|
+
* link-task | handoff | graph | cancel
|
|
329
|
+
*
|
|
330
|
+
* Orchestration-command decision (TASK-032):
|
|
331
|
+
* Both domains live under /session rather than splitting orchestration into
|
|
332
|
+
* a separate /session-orch command. Rationale: they share the same entity
|
|
333
|
+
* (a session) and the same operator mental model ("I am working with sessions").
|
|
334
|
+
* A second front-door would create ambiguity about which command to reach for.
|
|
335
|
+
* Explicit switch routing (not fallthrough) makes both domains first-class;
|
|
336
|
+
* the former /session-mgmt alias (session-mgmt/smgmt) is removed so there
|
|
337
|
+
* is exactly one registration and no silent shadowing.
|
|
323
338
|
*/
|
|
324
339
|
export const sessionCommand: SlashCommand = {
|
|
325
340
|
name: 'session',
|
|
326
341
|
aliases: ['sess'],
|
|
327
|
-
description: '
|
|
342
|
+
description: 'Session lifecycle and orchestration: list, resume, fork, save, export, link-task, handoff, graph, cancel.',
|
|
328
343
|
usage: '<subcommand> [args]',
|
|
329
|
-
argsHint: 'link-task|handoff|graph|cancel',
|
|
344
|
+
argsHint: 'list|rename|resume|fork|save|info|export|search|delete|events|groups|hotspots|link-task|handoff|graph|cancel',
|
|
330
345
|
handler: async (args: string[], context: CommandContext): Promise<void> => {
|
|
331
346
|
const [sub, ...rest] = args;
|
|
332
347
|
|
|
333
348
|
switch (sub) {
|
|
349
|
+
// ── Lifecycle subcommands ────────────────────────────────────────────────
|
|
350
|
+
// Each delegates explicitly to handleSessionWorkflowCommand so every
|
|
351
|
+
// subcommand has a deterministic, named path — no silent fallthrough.
|
|
352
|
+
case 'list':
|
|
353
|
+
case 'rename':
|
|
354
|
+
case 'resume':
|
|
355
|
+
case 'fork':
|
|
356
|
+
case 'save':
|
|
357
|
+
case 'info':
|
|
358
|
+
case 'export':
|
|
359
|
+
case 'search':
|
|
360
|
+
case 'delete':
|
|
361
|
+
case 'events':
|
|
362
|
+
case 'groups':
|
|
363
|
+
case 'hotspots':
|
|
364
|
+
await handleSessionWorkflowCommand(args, context);
|
|
365
|
+
break;
|
|
366
|
+
|
|
367
|
+
// ── Orchestration subcommands ─────────────────────────────────────────────
|
|
334
368
|
case 'link-task':
|
|
335
369
|
case 'link':
|
|
336
370
|
handleLinkTask(rest, context);
|
|
@@ -350,24 +384,40 @@ export const sessionCommand: SlashCommand = {
|
|
|
350
384
|
handleCancel(rest, context);
|
|
351
385
|
break;
|
|
352
386
|
|
|
387
|
+
// ── No-arg: show current session info ────────────────────────────────────
|
|
388
|
+
case undefined:
|
|
389
|
+
await handleSessionWorkflowCommand([], context);
|
|
390
|
+
break;
|
|
391
|
+
|
|
353
392
|
default: {
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
]
|
|
369
|
-
|
|
370
|
-
|
|
393
|
+
const usage = [
|
|
394
|
+
'Usage: /session <subcommand>',
|
|
395
|
+
'',
|
|
396
|
+
'Lifecycle:',
|
|
397
|
+
' list — List saved sessions',
|
|
398
|
+
' rename <name> — Rename the current session',
|
|
399
|
+
' resume <id|name> — Resume a saved session',
|
|
400
|
+
' fork [name] — Fork the current session',
|
|
401
|
+
' save [name] — Save the current session',
|
|
402
|
+
' info [id] — Show session info',
|
|
403
|
+
' export <id|.> [markdown|text] — Export session transcript',
|
|
404
|
+
' search <query> — Search session content',
|
|
405
|
+
' delete <id> — Delete a saved session',
|
|
406
|
+
' events [kind] — Show transcript events',
|
|
407
|
+
' groups [kind] — Show transcript groups',
|
|
408
|
+
' hotspots — Show transcript hotspots',
|
|
409
|
+
'',
|
|
410
|
+
'Orchestration:',
|
|
411
|
+
' link-task <taskId> [--session <sid>] [--depends-on <sid:taskId>] [--label <label>]',
|
|
412
|
+
' — Register a task in the cross-session graph',
|
|
413
|
+
' handoff <taskId> --to <sid> [--session <sid>] [--reason <reason>]',
|
|
414
|
+
' — Hand a task off to another session',
|
|
415
|
+
' graph [--session <sid>] [--format text|json]',
|
|
416
|
+
' — Display the cross-session task dependency graph',
|
|
417
|
+
' cancel <taskId> [--scope task|subtree|session] [--session <sid>] [--reason <reason>]',
|
|
418
|
+
' — Cancel tasks with scoped semantics',
|
|
419
|
+
].join('\n');
|
|
420
|
+
context.print(usage);
|
|
371
421
|
break;
|
|
372
422
|
}
|
|
373
423
|
}
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
resolveGithubToken,
|
|
18
18
|
} from '../../export/gist-uploader.ts';
|
|
19
19
|
import { copyToClipboard } from '../../utils/clipboard.ts';
|
|
20
|
-
import { openBrowser } from '../../
|
|
20
|
+
import { openBrowser } from '../../utils/browser.ts';
|
|
21
21
|
|
|
22
22
|
export function registerShareRuntimeCommands(registry: CommandRegistry): void {
|
|
23
23
|
registry.register({
|
|
@@ -3,7 +3,9 @@ import type { SelectionItem } from '../selection-modal.ts';
|
|
|
3
3
|
import { EFFORT_DESCRIPTIONS } from '@pellux/goodvibes-sdk/platform/providers';
|
|
4
4
|
import { REASONING_BUDGET_MAP } from '@pellux/goodvibes-sdk/platform/providers';
|
|
5
5
|
import { executeWriteQuit } from './quit-shared.ts';
|
|
6
|
-
import { compactConversation, requireKeybindingsManager, requireProviderApi } from './runtime-services.ts';
|
|
6
|
+
import { compactConversation, requireKeybindingsManager, requireProviderApi, requireSessionMemoryStore } from './runtime-services.ts';
|
|
7
|
+
import { buildCompactionPreview, buildCompactionAfterNotice, buildPinUsageText, buildPinSuccessText } from '../../renderer/compaction-preview.ts';
|
|
8
|
+
import { buildCompactionHistoryText } from '../../renderer/compaction-history-modal.ts';
|
|
7
9
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
8
10
|
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
9
11
|
|
|
@@ -204,9 +206,57 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
|
|
|
204
206
|
aliases: [],
|
|
205
207
|
description: 'Summarize conversation to free context window',
|
|
206
208
|
async handler(_args, ctx) {
|
|
207
|
-
ctx.
|
|
208
|
-
|
|
209
|
-
|
|
209
|
+
const messages = ctx.session.conversationManager.getMessagesForLLM();
|
|
210
|
+
// contextWindow is not on CommandContext; preview shows message/token counts
|
|
211
|
+
// without the capacity-% clause (still honest; no fabricated value).
|
|
212
|
+
const contextWindow = 0;
|
|
213
|
+
const memStore = ctx.session.sessionMemoryStore;
|
|
214
|
+
const pinnedMemoryCount = memStore ? memStore.list().length : 0;
|
|
215
|
+
// Pre-compact preview: honest estimate, clearly labelled.
|
|
216
|
+
const preview = buildCompactionPreview({ messages, contextWindow, pinnedMemoryCount, trigger: 'manual' });
|
|
217
|
+
ctx.print(preview);
|
|
218
|
+
const event = await compactConversation(ctx);
|
|
219
|
+
if (event) {
|
|
220
|
+
// Post-compact notice: uses real CompactionEvent figures.
|
|
221
|
+
ctx.print(buildCompactionAfterNotice({ event, pinnedMemoryCount }));
|
|
222
|
+
} else {
|
|
223
|
+
ctx.print('[Context] Compact complete.');
|
|
224
|
+
}
|
|
225
|
+
ctx.renderRequest();
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
registry.register({
|
|
230
|
+
name: 'compact-history',
|
|
231
|
+
aliases: ['compaction-history'],
|
|
232
|
+
description: 'Show compaction history for this session',
|
|
233
|
+
handler(_args, ctx) {
|
|
234
|
+
ctx.print(buildCompactionHistoryText());
|
|
235
|
+
ctx.renderRequest();
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
registry.register({
|
|
240
|
+
name: 'keep',
|
|
241
|
+
aliases: [],
|
|
242
|
+
description: 'Pin text to session memory (survives compaction)',
|
|
243
|
+
usage: '<text>',
|
|
244
|
+
argsHint: '<text to preserve>',
|
|
245
|
+
handler(args, ctx) {
|
|
246
|
+
const text = args.join(' ').trim();
|
|
247
|
+
if (!text) {
|
|
248
|
+
ctx.print(buildPinUsageText());
|
|
249
|
+
ctx.renderRequest();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const memStore = requireSessionMemoryStore(ctx);
|
|
253
|
+
const id = memStore.add(text);
|
|
254
|
+
if (!id) {
|
|
255
|
+
ctx.print('[Pin] Nothing pinned — text was blank.');
|
|
256
|
+
} else {
|
|
257
|
+
const count = memStore.list().length;
|
|
258
|
+
ctx.print(buildPinSuccessText(id, text, count));
|
|
259
|
+
}
|
|
210
260
|
ctx.renderRequest();
|
|
211
261
|
},
|
|
212
262
|
});
|
package/src/input/commands.ts
CHANGED
|
@@ -7,7 +7,6 @@ import { recallCommand } from './commands/memory.ts';
|
|
|
7
7
|
import { knowledgeCommand } from './commands/knowledge.ts';
|
|
8
8
|
import { registerShellCoreCommands } from './commands/shell-core.ts';
|
|
9
9
|
import { registerConfigCommand } from './commands/config.ts';
|
|
10
|
-
import { registerSessionWorkflowCommands } from './commands/session-workflow.ts';
|
|
11
10
|
import { registerDiscoveryRuntimeCommands } from './commands/discovery-runtime.ts';
|
|
12
11
|
import { registerPlanningRuntimeCommands } from './commands/planning-runtime.ts';
|
|
13
12
|
import { registerScheduleRuntimeCommands } from './commands/schedule-runtime.ts';
|
|
@@ -19,6 +18,7 @@ import { registerGitRuntimeCommands } from './commands/git-runtime.ts';
|
|
|
19
18
|
import { registerNotifyRuntimeCommands } from './commands/notify-runtime.ts';
|
|
20
19
|
import { registerReplayRuntimeCommands } from './commands/replay-runtime.ts';
|
|
21
20
|
import { registerShareRuntimeCommands } from './commands/share-runtime.ts';
|
|
21
|
+
import { registerChannelRuntimeCommands } from './commands/channel-runtime.ts';
|
|
22
22
|
import { registerLocalSetupCommands } from './commands/local-setup.ts';
|
|
23
23
|
import { registerProductRuntimeCommands } from './commands/product-runtime.ts';
|
|
24
24
|
import { registerPlatformRuntimeCommands } from './commands/platform-runtime.ts';
|
|
@@ -71,6 +71,7 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
|
|
|
71
71
|
registerNotifyRuntimeCommands(registry);
|
|
72
72
|
registerReplayRuntimeCommands(registry);
|
|
73
73
|
registerShareRuntimeCommands(registry);
|
|
74
|
+
registerChannelRuntimeCommands(registry);
|
|
74
75
|
registerLocalSetupCommands(registry);
|
|
75
76
|
registerProductRuntimeCommands(registry);
|
|
76
77
|
registerPlatformRuntimeCommands(registry);
|
|
@@ -107,7 +108,6 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
|
|
|
107
108
|
registerCloudflareRuntimeCommands(registry);
|
|
108
109
|
registerWorkPlanRuntimeCommands(registry);
|
|
109
110
|
registerLocalRuntimeCommands(registry);
|
|
110
|
-
registerSessionWorkflowCommands(registry);
|
|
111
111
|
registerDiscoveryRuntimeCommands(registry);
|
|
112
112
|
registerPlanningRuntimeCommands(registry);
|
|
113
113
|
registerScheduleRuntimeCommands(registry);
|
|
@@ -250,6 +250,15 @@ type SettingsRouteState = {
|
|
|
250
250
|
pendingProviderModelPickerTarget?: import('./model-picker.ts').ModelPickerTarget | null;
|
|
251
251
|
pendingSettingsPickerAction?: 'tts-provider' | 'tts-voice' | null;
|
|
252
252
|
resetSelected?: () => { key: string; value: unknown } | null;
|
|
253
|
+
initiateResetCategory?: () => void;
|
|
254
|
+
initiateResetAll?: () => void;
|
|
255
|
+
handleResetConfirmKey?: (
|
|
256
|
+
key: string,
|
|
257
|
+
) =>
|
|
258
|
+
| { result: 'confirmed'; entries: ReadonlyArray<{ key: string; value: unknown }> }
|
|
259
|
+
| 'cancelled'
|
|
260
|
+
| 'absorbed'
|
|
261
|
+
| 'inactive';
|
|
253
262
|
};
|
|
254
263
|
commandContext?: CommandContext;
|
|
255
264
|
/** Called when the settings modal requests the model picker for a non-main target. */
|
|
@@ -313,6 +322,28 @@ export function handleSettingsModalToken(state: SettingsRouteState, token: Input
|
|
|
313
322
|
}
|
|
314
323
|
}
|
|
315
324
|
|
|
325
|
+
// Reset confirm gate: routes all keys through the confirm contract before
|
|
326
|
+
// normal dispatch when a category or all-settings reset is pending.
|
|
327
|
+
if (state.settingsModal.handleResetConfirmKey) {
|
|
328
|
+
const key = token.type === 'key'
|
|
329
|
+
? (token.logicalName ?? '')
|
|
330
|
+
: token.type === 'text'
|
|
331
|
+
? token.value
|
|
332
|
+
: '';
|
|
333
|
+
const resetResult = state.settingsModal.handleResetConfirmKey(key);
|
|
334
|
+
if (resetResult !== 'inactive') {
|
|
335
|
+
if (typeof resetResult === 'object' && resetResult.result === 'confirmed') {
|
|
336
|
+
// Sync runtime for every reset entry so provider.model / reasoningEffort
|
|
337
|
+
// stay consistent with the live session without requiring a restart.
|
|
338
|
+
for (const entry of resetResult.entries) {
|
|
339
|
+
syncRuntimeAfterSettingReset(state.commandContext, entry.key, entry.value);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
state.requestRender();
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
316
347
|
if (token.type === 'key') {
|
|
317
348
|
const focusPane = state.settingsModal.focusPane ?? 'settings';
|
|
318
349
|
if (token.logicalName === 'escape') {
|
|
@@ -360,6 +391,12 @@ export function handleSettingsModalToken(state: SettingsRouteState, token: Input
|
|
|
360
391
|
} else if (state.settingsModal.moveFocusedDown) state.settingsModal.moveFocusedDown();
|
|
361
392
|
else state.settingsModal.moveDown?.();
|
|
362
393
|
}
|
|
394
|
+
else if (token.logicalName === 'r' && token.shift && token.ctrl && !state.settingsModal.editingMode && !state.settingsModal.searchFocused) {
|
|
395
|
+
state.settingsModal.initiateResetAll?.();
|
|
396
|
+
}
|
|
397
|
+
else if (token.logicalName === 'r' && token.shift && !state.settingsModal.editingMode && !state.settingsModal.searchFocused) {
|
|
398
|
+
state.settingsModal.initiateResetCategory?.();
|
|
399
|
+
}
|
|
363
400
|
else if (token.logicalName === 'r' && !state.settingsModal.editingMode && !state.settingsModal.searchFocused) {
|
|
364
401
|
const reset = state.settingsModal.resetSelected?.();
|
|
365
402
|
if (reset) syncRuntimeAfterSettingReset(state.commandContext, reset.key, reset.value);
|
|
@@ -232,11 +232,25 @@ export function handleModalTokenRoutes(state: ModalTokenRouteState, token: Input
|
|
|
232
232
|
return withState(state, true);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
235
|
+
// Agent detail modal: route c + confirm keys before escape-close.
|
|
236
|
+
// handleKey() consumes confirm-flow keys (y, Enter, n, Esc) and the 'c'
|
|
237
|
+
// initiator; unhandled keys (including Esc when no confirm is pending)
|
|
238
|
+
// fall through to escape-close below.
|
|
239
|
+
if (state.agentDetailModal.active) {
|
|
240
|
+
const keyStr: string =
|
|
241
|
+
token.type === 'key' ? (token.logicalName ?? '') :
|
|
242
|
+
token.type === 'text' ? token.value : '';
|
|
243
|
+
if (keyStr && state.agentDetailModal.handleKey(keyStr)) {
|
|
244
|
+
state.requestRender();
|
|
245
|
+
return withState(state, true);
|
|
246
|
+
}
|
|
247
|
+
// 'c' was not consumed (non-cancellable), or any other key.
|
|
248
|
+
// Esc closes the modal; all other keys are absorbed by the active modal.
|
|
249
|
+
if (token.type === 'key' && token.logicalName === 'escape') {
|
|
250
|
+
state.handleEscape();
|
|
251
|
+
return withState(state, true);
|
|
252
|
+
}
|
|
253
|
+
state.requestRender();
|
|
240
254
|
return withState(state, true);
|
|
241
255
|
}
|
|
242
256
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
formatOnboardingApplyCompletionMessage,
|
|
14
14
|
isLoopbackHostValue,
|
|
15
15
|
} from './onboarding/onboarding-verification-helpers.ts';
|
|
16
|
+
import { focusFirstOffendingField, getStepValidationErrors } from './onboarding/onboarding-wizard-validation.ts';
|
|
16
17
|
import type { ModelPickerTarget } from './model-picker.ts';
|
|
17
18
|
import { captureOnboardingWizardSnapshot, restoreOnboardingWizardSnapshot } from './handler-ui-state.ts';
|
|
18
19
|
import type { InputHandlerLike as InputHandler, OnboardingRuntimePosture } from './handler-types.ts';
|
|
@@ -82,6 +83,23 @@ function showOnboardingApplyFeedbackForHandler(handler: InputHandler, feedback:
|
|
|
82
83
|
|
|
83
84
|
function continueOnboardingSection(handler: InputHandler): void {
|
|
84
85
|
handler.onboardingWizard.commitEdit();
|
|
86
|
+
|
|
87
|
+
const step = handler.onboardingWizard.currentStep;
|
|
88
|
+
const { errors, firstOffendingFieldId } = getStepValidationErrors(handler.onboardingWizard, step);
|
|
89
|
+
if (errors.length > 0) {
|
|
90
|
+
handler.onboardingWizard.setApplyFeedback({
|
|
91
|
+
severity: 'error',
|
|
92
|
+
title: 'Required fields missing',
|
|
93
|
+
summary: 'Fill in the required fields below before continuing.',
|
|
94
|
+
messages: errors,
|
|
95
|
+
});
|
|
96
|
+
if (firstOffendingFieldId !== null) {
|
|
97
|
+
focusFirstOffendingField(handler.onboardingWizard, firstOffendingFieldId);
|
|
98
|
+
}
|
|
99
|
+
handler.requestRender();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
85
103
|
handler.onboardingWizard.clearApplyFeedback();
|
|
86
104
|
handler.onboardingWizard.nextStep();
|
|
87
105
|
handler.requestRender();
|
package/src/input/handler.ts
CHANGED
|
@@ -248,6 +248,7 @@ export class InputHandler implements InputHandlerLike {
|
|
|
248
248
|
sessionLogPathResolver: (agentId) => uiServices.environment.shellPaths.resolveProjectPath('tui', 'sessions', `${agentId}.jsonl`),
|
|
249
249
|
// SDK 0.23.0: supply wrfcController so the modal can show constraint data
|
|
250
250
|
wrfcController: uiServices.agents.wrfcController,
|
|
251
|
+
cancelAgent: (agentId: string) => uiServices.agents.agentManager.cancel(agentId),
|
|
251
252
|
});
|
|
252
253
|
this.bookmarkModal = new BookmarkModal(uiServices.shell.bookmarkManager);
|
|
253
254
|
this.sessionPickerModal = new SessionPickerModal(uiServices.sessions.sessionManager);
|
|
@@ -248,6 +248,16 @@ function addCloudflareOperations(
|
|
|
248
248
|
setConfig('cloudflare.maxQueueOpsPerDay', controller.getNumberFieldValue('cloudflare.max-queue-ops-per-day', config?.maxQueueOpsPerDay ?? 10000, 1));
|
|
249
249
|
setConfig('batch.mode', batchMode);
|
|
250
250
|
setConfig('batch.queueBackend', batchMode !== 'off' && components.queues ? 'cloudflare' : 'local');
|
|
251
|
+
// Zero Trust Tunnel auto-enables trustProxy on both services so the
|
|
252
|
+
// login-rate-limiter keys on the real CF-Connecting-IP rather than the tunnel
|
|
253
|
+
// egress address. RESIDUAL RISK: until the SDK validates CF-Connecting-IP
|
|
254
|
+
// against Cloudflare's published IP ranges (SDK handoff Item 5), a client
|
|
255
|
+
// that reaches the listener directly can spoof the header to bypass the
|
|
256
|
+
// per-IP limiter. The wizard surfaces this in the cloudflare step notice.
|
|
257
|
+
if (components.zeroTrustTunnel) {
|
|
258
|
+
setConfig('controlPlane.trustProxy', true);
|
|
259
|
+
setConfig('httpListener.trustProxy', true);
|
|
260
|
+
}
|
|
251
261
|
}
|
|
252
262
|
|
|
253
263
|
export function addNetworkOperations(
|
|
@@ -257,6 +257,19 @@ export function buildCloudflareStep(controller: OnboardingWizardControllerLike):
|
|
|
257
257
|
);
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
// Trust-proxy notice — shown when Tunnel is selected so the
|
|
261
|
+
// operator sees the security implication before applying.
|
|
262
|
+
const tunnelSelected = enabled && components.zeroTrustTunnel;
|
|
263
|
+
if (tunnelSelected) {
|
|
264
|
+
fields.push({
|
|
265
|
+
kind: 'status',
|
|
266
|
+
id: 'cloudflare.trust-proxy-notice',
|
|
267
|
+
label: 'trustProxy will be enabled for control plane and HTTP listener',
|
|
268
|
+
defaultValue: 'Notice',
|
|
269
|
+
hint: 'Selecting Zero Trust Tunnel auto-writes controlPlane.trustProxy=true and httpListener.trustProxy=true so the login rate-limiter keys on the real client IP (CF-Connecting-IP) rather than the tunnel egress address. RESIDUAL RISK: until the SDK validates CF-Connecting-IP against Cloudflare published IP ranges (handoff Item 5), a client that reaches the listener directly can spoof this header to bypass the per-IP rate-limiter. See docs/deployment-and-services.md for the full risk posture.',
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
260
273
|
if (components.zeroTrustAccess) {
|
|
261
274
|
fields.push(
|
|
262
275
|
{
|
|
@@ -463,6 +476,7 @@ export function buildCloudflareStep(controller: OnboardingWizardControllerLike):
|
|
|
463
476
|
`Components: ${enabled ? componentCount : 0} selected`,
|
|
464
477
|
`Token setup: ${enabled ? setupSource : 'not used'}`,
|
|
465
478
|
`Provision on final apply: ${enabled ? controller.getStringFieldValue('cloudflare.provision-on-apply', 'no') : 'no'}`,
|
|
479
|
+
...(enabled && components.zeroTrustTunnel ? ['trustProxy: enabled for control plane and HTTP listener (see security notice)'] : []),
|
|
466
480
|
],
|
|
467
481
|
fields,
|
|
468
482
|
};
|
|
@@ -621,6 +621,12 @@ export function buildNetworkStep(controller: OnboardingWizardControllerLike): On
|
|
|
621
621
|
}
|
|
622
622
|
}
|
|
623
623
|
|
|
624
|
+
if (controlPlaneRemote || listenerEnabled || browserEnabled) { // TLS warn + CORS notice.
|
|
625
|
+
const cpOff = controlPlaneRemote && String(controller.runtimeSnapshot?.config.controlPlane?.tls?.mode ?? 'off') === 'off';
|
|
626
|
+
const hlOff = listenerEnabled && String(controller.runtimeSnapshot?.config.httpListener?.tls?.mode ?? 'off') === 'off';
|
|
627
|
+
if (cpOff || hlOff) { const a = [cpOff ? 'control plane' : '', hlOff ? 'HTTP listener' : ''].filter(Boolean).join(' and '); fields.push({ kind: 'status', id: 'network.tls-warn', label: `TLS off — ${a} transmits plaintext`, defaultValue: 'Warning', hint: `The ${a} is network-reachable but TLS is off. Traffic travels in plaintext. Enable TLS or use a terminating reverse proxy.` }); }
|
|
628
|
+
if (listenerEnabled) { fields.push({ kind: 'status', id: 'network.cors-note', label: 'CORS must be configured manually', defaultValue: 'Info', hint: 'httpListener.enforceCors and httpListener.allowedOrigins are not in ConfigKey union (SDK handoff Item 5). Edit ~/.goodvibes/tui/settings.json to set them, then restart the daemon.' }); }
|
|
629
|
+
}
|
|
624
630
|
return {
|
|
625
631
|
id: 'network',
|
|
626
632
|
title: 'Network setup',
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { normalizeText } from './onboarding-wizard-helpers.ts';
|
|
2
|
+
import type { OnboardingWizardControllerLike } from './onboarding-wizard-types.ts';
|
|
3
|
+
import type { OnboardingWizardFieldDefinition, OnboardingWizardStepDefinition } from './onboarding-wizard-types.ts';
|
|
4
|
+
|
|
5
|
+
export interface WizardStepValidationResult {
|
|
6
|
+
/** Human-readable error strings for each violating field. */
|
|
7
|
+
readonly errors: readonly string[];
|
|
8
|
+
/** ID of the first field that has an error, or null when all pass. */
|
|
9
|
+
readonly firstOffendingFieldId: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validate all fields on a single wizard step, checking:
|
|
14
|
+
* - required text / masked fields that are empty
|
|
15
|
+
* - required acknowledgement fields that are unchecked
|
|
16
|
+
* - any general field-level validation errors (via getFieldValidationError)
|
|
17
|
+
*
|
|
18
|
+
* Returns per-field error messages and the first offending field id so the
|
|
19
|
+
* caller can block navigation and jump focus to the first problem.
|
|
20
|
+
*/
|
|
21
|
+
export function getStepValidationErrors(
|
|
22
|
+
controller: OnboardingWizardControllerLike,
|
|
23
|
+
step: OnboardingWizardStepDefinition,
|
|
24
|
+
): WizardStepValidationResult {
|
|
25
|
+
const errors: string[] = [];
|
|
26
|
+
let firstOffendingFieldId: string | null = null;
|
|
27
|
+
|
|
28
|
+
for (const field of step.fields) {
|
|
29
|
+
const error = getFieldError(controller, step, field);
|
|
30
|
+
if (error !== null) {
|
|
31
|
+
errors.push(error);
|
|
32
|
+
if (firstOffendingFieldId === null) firstOffendingFieldId = field.id;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { errors, firstOffendingFieldId };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getFieldError(
|
|
40
|
+
controller: OnboardingWizardControllerLike,
|
|
41
|
+
step: OnboardingWizardStepDefinition,
|
|
42
|
+
field: OnboardingWizardFieldDefinition,
|
|
43
|
+
): string | null {
|
|
44
|
+
// Required acknowledgement not checked
|
|
45
|
+
if (field.kind === 'acknowledgement' && field.required) {
|
|
46
|
+
if (!controller.isFieldSatisfied(field)) {
|
|
47
|
+
return `${step.shortLabel}: ${field.label} must be acknowledged before continuing.`;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Required text / masked field that is empty
|
|
53
|
+
if ((field.kind === 'text' || field.kind === 'masked') && field.required === true) {
|
|
54
|
+
const value = normalizeText(controller.getFieldValue(field) as string);
|
|
55
|
+
if (value.length === 0) {
|
|
56
|
+
return `${step.shortLabel}: ${field.label} is required.`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Delegate all other field-level validation (format errors, port range, etc.)
|
|
61
|
+
return controller.getFieldValidationError(step, field);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Focus the first offending field on the current step by mutating the
|
|
66
|
+
* controller's selectedFieldIndices. The renderer will pick up the change on
|
|
67
|
+
* the next paint cycle.
|
|
68
|
+
*/
|
|
69
|
+
export function focusFirstOffendingField(
|
|
70
|
+
controller: OnboardingWizardControllerLike,
|
|
71
|
+
fieldId: string,
|
|
72
|
+
): void {
|
|
73
|
+
const fields = controller.currentStep.fields;
|
|
74
|
+
const index = fields.findIndex((f) => f.id === fieldId);
|
|
75
|
+
if (index < 0) return;
|
|
76
|
+
controller.selectedFieldIndices[controller.stepIndex] = index;
|
|
77
|
+
}
|
|
@@ -33,5 +33,10 @@ export function getNumericAdjustmentMeta(setting: ConfigSetting): {
|
|
|
33
33
|
if (setting.key === 'wrfc.scoreThreshold') {
|
|
34
34
|
return { step: 0.1, min: 0, max: 10, precision: 1 };
|
|
35
35
|
}
|
|
36
|
+
if ((setting.key as string) === 'tts.speed') {
|
|
37
|
+
// Speed multiplier: 0.1 increments, min 0.1, no hard max (provider-defined).
|
|
38
|
+
// tts.speed is not yet a ConfigKey in the SDK schema; cast required.
|
|
39
|
+
return { step: 0.1, min: 0.1, precision: 1 };
|
|
40
|
+
}
|
|
36
41
|
return { step: 1, precision: 0 };
|
|
37
42
|
}
|