Skip to content

Commit 86a42a2

Browse files
authored
feat: Add persistent experimental feature toggles and a TUI panel (#420)
* feat: Add persistent experimental feature toggles and a TUI panel * test(agent-core): isolate experimental flag tests * fix(tui): block experiments panel while busy * refactor(experimental): merge feature query APIs
1 parent d0f8e24 commit 86a42a2

62 files changed

Lines changed: 1371 additions & 163 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@moonshot-ai/agent-core": minor
3+
"@moonshot-ai/kimi-code-sdk": minor
4+
"@moonshot-ai/kimi-code": minor
5+
---
6+
7+
Add persistent experimental feature toggles and a TUI panel that applies confirmed changes by reloading the current session.

apps/kimi-code/src/cli/run-prompt.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from '@moonshot-ai/kimi-code-sdk';
2020

2121
import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app';
22+
import { experimentalFeatureMap } from '#/utils/experimental-features';
2223

2324
import type { CLIOptions, PromptOutputFormat } from './options';
2425
import {
@@ -146,8 +147,8 @@ export async function runPrompt(
146147
// the turn-run alive across continuation turns, so the normal prompt-turn
147148
// waiter blocks until the goal is terminal; we then emit a summary and set a
148149
// distinct exit code.
149-
const flagMap = await harness.getExperimentalFlags();
150-
const goalCreate = parseHeadlessGoalCreate(opts.prompt!, flagMap['goal-command'] === true);
150+
const flagMap = experimentalFeatureMap(await harness.getExperimentalFeatures());
151+
const goalCreate = parseHeadlessGoalCreate(opts.prompt!, flagMap['goal_command'] === true);
151152
if (goalCreate !== undefined) {
152153
await runHeadlessGoal(session, goalCreate, goalModel, outputFormat, stdout, stderr);
153154
} else {
@@ -466,6 +467,7 @@ function runPromptTurn(
466467
case 'compaction.completed':
467468
case 'compaction.started':
468469
case 'cron.fired':
470+
case 'goal.updated':
469471
case 'mcp.server.status':
470472
case 'session.meta.updated':
471473
case 'skill.activated':

apps/kimi-code/src/tui/commands/config.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1-
import type { PermissionMode, Session } from '@moonshot-ai/kimi-code-sdk';
1+
import type {
2+
ExperimentalFeatureState,
3+
FlagId,
4+
PermissionMode,
5+
Session,
6+
} from '@moonshot-ai/kimi-code-sdk';
27

38
import { EditorSelectorComponent } from '../components/dialogs/editor-selector';
9+
import {
10+
ExperimentsSelectorComponent,
11+
type ExperimentalFeatureDraftChange,
12+
} from '../components/dialogs/experiments-selector';
413
import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector';
514
import { PermissionSelectorComponent } from '../components/dialogs/permission-selector';
615
import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector';
@@ -12,6 +21,7 @@ import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
1221
import { isTheme } from '../theme/index';
1322
import { formatErrorMessage } from '../utils/event-payload';
1423
import { showUsage } from './info';
24+
import { setExperimentalFeatures } from './experimental-flags';
1525
import type { SlashCommandHost } from './dispatch';
1626

1727
// ---------------------------------------------------------------------------
@@ -421,6 +431,73 @@ export function showUpdatePreferencePicker(host: SlashCommandHost): void {
421431
);
422432
}
423433

434+
export async function showExperimentsPanel(host: SlashCommandHost): Promise<void> {
435+
let features: readonly ExperimentalFeatureState[];
436+
try {
437+
features = await host.harness.getExperimentalFeatures();
438+
} catch (error) {
439+
host.showError(`Failed to load experimental features: ${formatErrorMessage(error)}`);
440+
return;
441+
}
442+
mountExperimentsPanel(host, features);
443+
}
444+
445+
export async function applyExperimentalFeatureChanges(
446+
host: SlashCommandHost,
447+
changes: readonly ExperimentalFeatureDraftChange[],
448+
): Promise<void> {
449+
if (changes.length === 0) {
450+
host.showStatus(
451+
'No experimental feature changes to apply.',
452+
host.state.theme.colors.textMuted,
453+
);
454+
return;
455+
}
456+
457+
const experimental: Partial<Record<FlagId, boolean>> = {};
458+
for (const change of changes) {
459+
experimental[change.id] = change.enabled;
460+
}
461+
462+
try {
463+
await host.harness.setConfig({ experimental });
464+
const features = await host.harness.getExperimentalFeatures();
465+
setExperimentalFeatures(features);
466+
host.refreshSlashCommandAutocomplete();
467+
host.restoreEditor();
468+
if (host.session !== undefined) {
469+
await host.session.reloadSession();
470+
await host.reloadCurrentSessionView(
471+
host.session,
472+
'Experimental features updated. Session reloaded.',
473+
);
474+
} else {
475+
host.showStatus('Experimental features updated.', host.state.theme.colors.success);
476+
}
477+
host.track('experimental_features_apply', { changed: changes.length });
478+
} catch (error) {
479+
host.showError(`Failed to update experimental features: ${formatErrorMessage(error)}`);
480+
}
481+
}
482+
483+
function mountExperimentsPanel(
484+
host: SlashCommandHost,
485+
features: readonly ExperimentalFeatureState[],
486+
): void {
487+
host.mountEditorReplacement(
488+
new ExperimentsSelectorComponent({
489+
features,
490+
colors: host.state.theme.colors,
491+
onApply: (changes) => {
492+
void applyExperimentalFeatureChanges(host, changes);
493+
},
494+
onCancel: () => {
495+
host.restoreEditor();
496+
},
497+
}),
498+
);
499+
}
500+
424501
type UpdatePreferenceHost = {
425502
readonly state: {
426503
readonly appState: Pick<
@@ -503,6 +580,7 @@ function handleSettingsSelection(host: SlashCommandHost, value: SettingsSelectio
503580
case 'permission': showPermissionPicker(host); return;
504581
case 'theme': showThemePicker(host); return;
505582
case 'editor': showEditorPicker(host); return;
583+
case 'experiments': void showExperimentsPanel(host); return;
506584
case 'upgrade': showUpdatePreferencePicker(host); return;
507585
case 'usage': void showUsage(host); return;
508586
}

apps/kimi-code/src/tui/commands/dispatch.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
handlePlanCommand,
3333
handleThemeCommand,
3434
handleYoloCommand,
35+
showExperimentsPanel,
3536
showModelPicker,
3637
showPermissionPicker,
3738
showSettingsSelector,
@@ -68,6 +69,7 @@ export {
6869
handleThemeCommand,
6970
handleYoloCommand,
7071
showModelPicker,
72+
showExperimentsPanel,
7173
showPermissionPicker,
7274
showSettingsSelector,
7375
} from './config';
@@ -109,6 +111,7 @@ export interface SlashCommandHost {
109111
mountEditorReplacement(panel: Component & Focusable): void;
110112
restoreEditor(): void;
111113
restoreInputText(text: string): void;
114+
refreshSlashCommandAutocomplete(): void;
112115

113116
// Session
114117
requireSession(): Session;
@@ -171,6 +174,13 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi
171174
host.track('input_command_invalid', { reason: 'blocked', command: intent.commandName });
172175
host.showError(slashBusyMessage(intent.commandName, intent.reason));
173176
return;
177+
case 'invalid':
178+
host.track('input_command_invalid', {
179+
reason: intent.reason,
180+
command: intent.commandName,
181+
});
182+
host.showError(`Invalid slash command: /${intent.commandName}`);
183+
return;
174184
case 'skill': {
175185
const session = host.session;
176186
if (host.state.appState.model.trim().length === 0 || session === undefined) {
@@ -238,6 +248,9 @@ async function handleBuiltInSlashCommand(
238248
case 'plugins':
239249
void handlePluginsCommand(host, args);
240250
return;
251+
case 'experiments':
252+
await showExperimentsPanel(host);
253+
return;
241254
case 'reload':
242255
await handleReloadCommand(host);
243256
return;

apps/kimi-code/src/tui/commands/experimental-flags.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
import type { ExperimentalFlagMap } from '@moonshot-ai/kimi-code-sdk';
1+
import type { ExperimentalFeatureState, ExperimentalFlagMap } from '@moonshot-ai/kimi-code-sdk';
22

3-
// Resolved experimental flags, fetched once from the core over RPC at startup and then read
3+
import { experimentalFeatureMap } from '#/utils/experimental-features';
4+
5+
// Resolved experimental features, fetched once from the core over RPC at startup and then read
46
// synchronously by the command palette and dispatch. App-local cache, not a source of truth.
57
let snapshot: ExperimentalFlagMap = {};
68

7-
/** Replace the cached flag snapshot. Call once after fetching via `harness.getExperimentalFlags()`. */
8-
export function setExperimentalFlags(flags: ExperimentalFlagMap): void {
9-
snapshot = flags;
9+
/** Replace the cached flag snapshot. Call after fetching via `harness.getExperimentalFeatures()`. */
10+
export function setExperimentalFeatures(
11+
features: readonly Pick<ExperimentalFeatureState, 'id' | 'enabled'>[],
12+
): void {
13+
snapshot = experimentalFeatureMap(features);
1014
}
1115

1216
/** An `undefined` flag means "not gated" → always enabled, so callers can pass an optional flag id. */

apps/kimi-code/src/tui/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export {
1818
handlePlanCommand,
1919
handleThemeCommand,
2020
handleYoloCommand,
21+
showExperimentsPanel,
2122
showModelPicker,
2223
showPermissionPicker,
2324
showSettingsSelector,

apps/kimi-code/src/tui/commands/registry.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ export const BUILTIN_SLASH_COMMANDS = [
114114
priority: 60,
115115
availability: 'always',
116116
},
117+
{
118+
name: 'experiments',
119+
aliases: ['experimental'],
120+
description: 'Manage experimental features',
121+
priority: 60,
122+
availability: 'idle-only',
123+
},
117124
{
118125
name: 'reload',
119126
aliases: [],
@@ -139,7 +146,7 @@ export const BUILTIN_SLASH_COMMANDS = [
139146
aliases: [],
140147
description: 'Start or manage an autonomous goal',
141148
priority: 80,
142-
experimentalFlag: 'goal-command',
149+
experimentalFlag: 'goal_command',
143150
// No argumentHint: the menu description stays as short as every other
144151
// command's. The subcommands (status/pause/resume/cancel/replace) surface in
145152
// the argument autocomplete list once the user types `/goal ` (see

apps/kimi-code/src/tui/commands/reload.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk';
22

33
import { loadTuiConfig, type TuiConfig } from '../config';
44
import type { SlashCommandHost } from './dispatch';
5+
import { setExperimentalFeatures } from './experimental-flags';
56

67
export async function handleReloadTuiCommand(host: SlashCommandHost): Promise<void> {
78
const tuiConfig = await loadTuiConfig();
@@ -19,6 +20,8 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise<void>
1920
}
2021

2122
const config = await host.harness.getConfig({ reload: true });
23+
setExperimentalFeatures(await host.harness.getExperimentalFeatures());
24+
host.refreshSlashCommandAutocomplete();
2225
applyRuntimeConfig(host, config);
2326
applyReloadedTuiConfig(host, tuiConfig);
2427

0 commit comments

Comments
 (0)