@pellux/goodvibes-tui 1.7.0 → 1.9.1
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 +11 -0
- package/README.md +10 -9
- package/docs/foundation-artifacts/operator-contract.json +4045 -1763
- package/package.json +2 -2
- package/src/audio/spoken-turn-controller.ts +12 -2
- package/src/audio/spoken-turn-wiring.ts +2 -1
- package/src/cli/help.ts +8 -1
- package/src/cli/service-posture.ts +2 -1
- package/src/cli/status.ts +2 -1
- package/src/cli/surface-command.ts +2 -2
- package/src/config/credential-availability.ts +122 -0
- package/src/config/index.ts +15 -0
- package/src/core/composer-state.ts +11 -3
- package/src/core/conversation-line-cache.ts +27 -3
- package/src/core/conversation-rendering.ts +71 -14
- package/src/core/stream-event-wiring.ts +20 -1
- package/src/core/system-message-noise.ts +87 -0
- package/src/core/system-message-router.ts +68 -1
- package/src/core/turn-cancellation.ts +7 -2
- package/src/core/turn-event-wiring.ts +10 -2
- package/src/daemon/cli.ts +29 -2
- package/src/daemon/handlers/register.ts +8 -1
- package/src/daemon/service-commands.ts +329 -0
- package/src/input/autocomplete.ts +27 -1
- package/src/input/command-registry.ts +46 -4
- package/src/input/commands/codebase-runtime.ts +46 -6
- package/src/input/commands/config.ts +43 -3
- package/src/input/commands/health-runtime.ts +9 -1
- package/src/input/commands/memory.ts +68 -35
- package/src/input/commands/operator-panel-runtime.ts +31 -7
- package/src/input/commands/planning-runtime.ts +95 -6
- package/src/input/commands/qrcode-runtime.ts +25 -5
- package/src/input/commands/remote-runtime-setup.ts +5 -3
- package/src/input/commands/session-content.ts +20 -9
- package/src/input/commands/settings-sync-runtime.ts +15 -3
- package/src/input/commands/shell-core.ts +10 -1
- package/src/input/commands/workstream-runtime.ts +168 -18
- package/src/input/config-modal-types.ts +15 -1
- package/src/input/config-modal.ts +227 -12
- package/src/input/feed-context-factory.ts +3 -0
- package/src/input/handler-command-route.ts +10 -3
- package/src/input/handler-content-actions.ts +17 -2
- package/src/input/handler-feed-routes.ts +43 -121
- package/src/input/handler-feed.ts +32 -4
- package/src/input/handler-modal-routes.ts +78 -13
- package/src/input/handler-modal-stack.ts +11 -2
- package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
- package/src/input/handler-onboarding.ts +71 -59
- package/src/input/handler-picker-routes.ts +15 -8
- package/src/input/handler-shortcuts.ts +59 -9
- package/src/input/handler.ts +6 -1
- package/src/input/keybindings.ts +6 -5
- package/src/input/model-picker.ts +19 -2
- package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
- package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
- package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
- package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
- package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
- package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
- package/src/input/panel-mouse-geometry.ts +97 -0
- package/src/input/panel-paste-flood-guard.ts +86 -0
- package/src/input/selection-modal.ts +11 -0
- package/src/input/session-picker-modal.ts +44 -4
- package/src/input/settings-modal-data.ts +51 -2
- package/src/input/settings-modal-types.ts +4 -2
- package/src/main.ts +28 -27
- package/src/panels/builtin/shared.ts +9 -3
- package/src/panels/fleet-deep-link.ts +31 -0
- package/src/panels/fleet-panel-format.ts +62 -0
- package/src/panels/fleet-panel-worktree-detail.ts +48 -0
- package/src/panels/fleet-panel.ts +89 -131
- package/src/panels/fleet-read-model.ts +43 -0
- package/src/panels/fleet-steer.ts +35 -2
- package/src/panels/fleet-stop.ts +29 -1
- package/src/panels/modals/keybindings-modal.ts +16 -1
- package/src/panels/modals/modal-theme.ts +35 -29
- package/src/panels/modals/pairing-modal.ts +25 -6
- package/src/panels/modals/planning-modal.ts +43 -21
- package/src/panels/modals/work-plan-modal.ts +28 -0
- package/src/panels/panel-manager.ts +15 -4
- package/src/panels/polish-core.ts +38 -25
- package/src/panels/project-planning-answer-actions.ts +8 -13
- package/src/panels/types.ts +24 -0
- package/src/permissions/prompt.ts +160 -13
- package/src/renderer/autocomplete-overlay.ts +28 -2
- package/src/renderer/compositor.ts +2 -3
- package/src/renderer/config-modal.ts +19 -5
- package/src/renderer/footer-tips.ts +5 -1
- package/src/renderer/fullscreen-primitives.ts +32 -22
- package/src/renderer/git-status.ts +3 -1
- package/src/renderer/layout.ts +0 -4
- package/src/renderer/markdown.ts +7 -3
- package/src/renderer/modal-factory.ts +25 -20
- package/src/renderer/model-workspace.ts +18 -3
- package/src/renderer/overlay-box.ts +21 -17
- package/src/renderer/process-indicator.ts +14 -3
- package/src/renderer/selection-modal-overlay.ts +6 -1
- package/src/renderer/session-picker-modal.ts +196 -3
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +7 -0
- package/src/renderer/shell-surface.ts +8 -1
- package/src/renderer/status-glyphs.ts +14 -15
- package/src/renderer/system-message.ts +15 -3
- package/src/renderer/terminal-bg-probe.ts +339 -0
- package/src/renderer/terminal-escapes.ts +20 -0
- package/src/renderer/theme-mode-config.ts +67 -0
- package/src/renderer/theme.ts +91 -1
- package/src/renderer/thinking.ts +11 -3
- package/src/renderer/tool-call.ts +15 -9
- package/src/renderer/tool-result-summary.ts +148 -0
- package/src/renderer/turn-injection.ts +22 -3
- package/src/renderer/ui-factory.ts +154 -85
- package/src/renderer/ui-primitives.ts +30 -129
- package/src/runtime/bootstrap-command-context.ts +6 -0
- package/src/runtime/bootstrap-command-parts.ts +8 -4
- package/src/runtime/bootstrap-core.ts +46 -24
- package/src/runtime/bootstrap-hook-bridge.ts +7 -0
- package/src/runtime/bootstrap-shell.ts +19 -1
- package/src/runtime/bootstrap.ts +118 -5
- package/src/runtime/code-index-services.ts +25 -2
- package/src/runtime/legacy-daemon-migration.ts +516 -0
- package/src/runtime/memory-fold.ts +55 -0
- package/src/runtime/onboarding/derivation.ts +7 -2
- package/src/runtime/onboarding/snapshot.ts +27 -1
- package/src/runtime/onboarding/types.ts +31 -1
- package/src/runtime/operator-token-cleanup.ts +82 -1
- package/src/runtime/orchestrator-core-services.ts +10 -0
- package/src/runtime/resume-notice.ts +209 -0
- package/src/runtime/services.ts +12 -8
- package/src/runtime/session-inbound-inputs.ts +252 -0
- package/src/runtime/session-spine-transport.ts +64 -0
- package/src/runtime/terminal-output-guard.ts +15 -8
- package/src/runtime/ui-services.ts +19 -3
- package/src/runtime/workstream-services.ts +160 -28
- package/src/runtime/wrfc-persistence.ts +124 -17
- package/src/shell/blocking-input.ts +46 -3
- package/src/shell/recovery-input-helpers.ts +170 -1
- package/src/shell/ui-openers.ts +42 -9
- package/src/utils/terminal-width.ts +52 -0
- package/src/version.ts +1 -1
|
@@ -8,12 +8,15 @@ import type { CommandRegistry } from '../command-registry.ts';
|
|
|
8
8
|
import { openModalCommand, requirePlanManager, requireSessionLineageTracker } from './runtime-services.ts';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Single-token verbs that look like a `/plan` subcommand but are not real ones
|
|
12
|
-
*
|
|
13
|
-
* never
|
|
14
|
-
*
|
|
11
|
+
* Single-token verbs that look like a `/plan` subcommand but are not real ones.
|
|
12
|
+
* A lone one of these is refused rather than seeded as a goal, so a stray verb
|
|
13
|
+
* can never overwrite the project goal with itself.
|
|
14
|
+
*
|
|
15
|
+
* DEBT-3: `dismiss` and `answer` are now REAL subcommands (handled above this
|
|
16
|
+
* guard), so they were removed from the refuse-list. `pause`/`stop`/`cancel`
|
|
17
|
+
* remain here — they still have no backing verb and must not seed a goal.
|
|
15
18
|
*/
|
|
16
|
-
const PSEUDO_SUBCOMMAND_VERBS = new Set(['
|
|
19
|
+
const PSEUDO_SUBCOMMAND_VERBS = new Set(['pause', 'stop', 'cancel']);
|
|
17
20
|
|
|
18
21
|
function recordNextQuestion(
|
|
19
22
|
state: Partial<ProjectPlanningState>,
|
|
@@ -59,7 +62,7 @@ export function registerPlanningRuntimeCommands(registry: CommandRegistry): void
|
|
|
59
62
|
registry.register({
|
|
60
63
|
name: 'plan',
|
|
61
64
|
description: 'Inspect or seed TUI-owned project planning state',
|
|
62
|
-
usage: '[panel | approve | list | show <id> | mode | explain | override <strategy> | status | clear | <planning goal>]',
|
|
65
|
+
usage: '[panel | approve | dismiss | answer <n> <text> | list | show <id> | mode | explain | override <strategy> | status | clear | <planning goal>]',
|
|
63
66
|
argsHint: '[panel|approve|status|<goal>]',
|
|
64
67
|
async handler(args, ctx) {
|
|
65
68
|
const planManager = requirePlanManager(ctx);
|
|
@@ -173,6 +176,92 @@ export function registerPlanningRuntimeCommands(registry: CommandRegistry): void
|
|
|
173
176
|
return;
|
|
174
177
|
}
|
|
175
178
|
|
|
179
|
+
// DEBT-3: /plan dismiss — archive the current plan. Dismisses the active
|
|
180
|
+
// execution plan (ExecutionPlanManager.dismiss, honest per-state) AND
|
|
181
|
+
// deactivates the project-planning interview state shown in the modal so a
|
|
182
|
+
// later /plan <goal> starts fresh. Mid-execution is refused outright.
|
|
183
|
+
if (args[0] === 'dismiss') {
|
|
184
|
+
const dismissal = planManager.dismiss(ctx.session.runtime.sessionId);
|
|
185
|
+
if (dismissal.outcome === 'requires-cancel') {
|
|
186
|
+
ctx.print(
|
|
187
|
+
`Plan "${dismissal.blockedBy?.title ?? 'active plan'}" is mid-execution and was not dismissed. ` +
|
|
188
|
+
`Run /workstream cancel to stop it first, then /plan dismiss.`,
|
|
189
|
+
);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
let planningNote = '';
|
|
193
|
+
if (projectPlanningService && projectId) {
|
|
194
|
+
const current = await projectPlanningService.getState({ projectId });
|
|
195
|
+
if (current.state && current.state.metadata?.['active'] === true) {
|
|
196
|
+
await projectPlanningService.upsertState({
|
|
197
|
+
projectId,
|
|
198
|
+
state: {
|
|
199
|
+
...current.state,
|
|
200
|
+
metadata: {
|
|
201
|
+
...(current.state.metadata ?? {}),
|
|
202
|
+
active: false,
|
|
203
|
+
dismissedAt: Date.now(),
|
|
204
|
+
dismissedFrom: 'plan-command',
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
planningNote = ' Project planning interview marked inactive.';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (dismissal.outcome === 'dismissed') {
|
|
212
|
+
ctx.print(
|
|
213
|
+
`Dismissed plan "${dismissal.plan?.title ?? 'active plan'}" ` +
|
|
214
|
+
`(archived as dismissed; retained in /plan list; /plan <goal> starts fresh).${planningNote}`,
|
|
215
|
+
);
|
|
216
|
+
} else if (planningNote) {
|
|
217
|
+
ctx.print(`No active execution plan to dismiss.${planningNote}`);
|
|
218
|
+
} else {
|
|
219
|
+
ctx.print('No active plan or planning state to dismiss.');
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// DEBT-3: /plan answer <n|question-id> <text> — record a real answer to an
|
|
225
|
+
// open planning question (moves open → answered, consumed on next refine).
|
|
226
|
+
if (args[0] === 'answer') {
|
|
227
|
+
if (!projectPlanningService || !projectId) {
|
|
228
|
+
ctx.print('Project planning service is not available in this runtime.');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const ref = args[1];
|
|
232
|
+
const answerText = args.slice(2).join(' ').trim();
|
|
233
|
+
if (!ref || !answerText) {
|
|
234
|
+
ctx.print('Usage: /plan answer <question-number|question-id> <your answer>');
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const asNum = Number(ref);
|
|
238
|
+
const selector = Number.isInteger(asNum) && asNum >= 1
|
|
239
|
+
? { questionIndex: asNum - 1 }
|
|
240
|
+
: { questionId: ref };
|
|
241
|
+
const answerResult = await projectPlanningService.answerQuestion({ projectId, ...selector, answer: answerText });
|
|
242
|
+
if (!answerResult.answered) {
|
|
243
|
+
if (answerResult.reason === 'no-state') {
|
|
244
|
+
ctx.print('No project planning state exists yet. Seed it with /plan <goal>.');
|
|
245
|
+
} else if (answerResult.reason === 'question-not-found') {
|
|
246
|
+
const open = answerResult.openQuestions;
|
|
247
|
+
const listing = open.length > 0
|
|
248
|
+
? open.map((question, index) => ` ${index + 1}. ${question.prompt} (${question.id})`).join('\n')
|
|
249
|
+
: ' (no open questions)';
|
|
250
|
+
ctx.print(`No open question matched "${ref}". Open questions:\n${listing}`);
|
|
251
|
+
} else {
|
|
252
|
+
ctx.print('Usage: /plan answer <question-number|question-id> <your answer>');
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
openProjectPlanningPanel();
|
|
257
|
+
ctx.print(
|
|
258
|
+
`Recorded answer to: ${answerResult.question?.prompt ?? 'question'}\n` +
|
|
259
|
+
`Readiness: ${answerResult.evaluation.readiness}\n` +
|
|
260
|
+
formatNextQuestion(answerResult.evaluation.nextQuestion),
|
|
261
|
+
);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
176
265
|
// Defense (Wave 6 review): a single verb-looking token is almost never a
|
|
177
266
|
// real planning goal — it is a mistyped or removed subcommand. The
|
|
178
267
|
// Planning modal used to dispatch `/plan dismiss`, which has no
|
|
@@ -1,18 +1,38 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
1
2
|
import type { CommandRegistry } from '../command-registry.ts';
|
|
2
|
-
import { openModalCommand } from './runtime-services.ts';
|
|
3
|
+
import { openModalCommand, requireShellPaths } from './runtime-services.ts';
|
|
4
|
+
import { regenerateCompanionToken } from '@pellux/goodvibes-sdk/platform/pairing';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Register the /qrcode command.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
+
* `/qrcode` (no args) opens the companion-app pairing modal (QR code + URL/
|
|
10
|
+
* token/username). `/qrcode regenerate` rotates the companion pairing token:
|
|
11
|
+
* it re-keys the shared operator-token store, so the previously-issued token is
|
|
12
|
+
* rejected on the next auth and a fresh QR must be scanned to reconnect.
|
|
9
13
|
*/
|
|
10
14
|
export function registerQrcodeRuntimeCommands(registry: CommandRegistry): void {
|
|
11
15
|
registry.register({
|
|
12
16
|
name: 'qrcode',
|
|
13
17
|
aliases: ['qr', 'pair'],
|
|
14
|
-
description: 'Open the companion-app pairing modal (QR code)',
|
|
15
|
-
|
|
18
|
+
description: 'Open the companion-app pairing modal (QR code), or regenerate the pairing token',
|
|
19
|
+
usage: '[regenerate]',
|
|
20
|
+
argsHint: '[regenerate]',
|
|
21
|
+
handler(args, ctx) {
|
|
22
|
+
if (args[0]?.toLowerCase() === 'regenerate') {
|
|
23
|
+
const shellPaths = requireShellPaths(ctx);
|
|
24
|
+
const daemonHomeDir = join(shellPaths.homeDirectory, '.goodvibes', 'daemon');
|
|
25
|
+
try {
|
|
26
|
+
const rotated = regenerateCompanionToken({ daemonHomeDir });
|
|
27
|
+
ctx.print(
|
|
28
|
+
'Pairing token rotated. The previous token is now rejected — ' +
|
|
29
|
+
`re-scan the QR (new peer ${rotated.peerId.slice(0, 8)}…) to reconnect the companion app.`,
|
|
30
|
+
);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
ctx.print(`Could not regenerate the pairing token: ${String(error)}`);
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
16
36
|
openModalCommand(ctx, 'pairing-modal');
|
|
17
37
|
},
|
|
18
38
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, resolve } from 'node:path';
|
|
3
3
|
import { getDefaultAcpAgentCommand } from '@pellux/goodvibes-sdk/platform/acp';
|
|
4
|
+
import { resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
|
|
4
5
|
import type { CommandContext, RemoteCommandService } from '../command-registry.ts';
|
|
5
6
|
import type { RemoteSessionBundle } from '@/runtime/index.ts';
|
|
6
7
|
import { requireShellPaths } from './runtime-services.ts';
|
|
@@ -37,10 +38,11 @@ export async function handleRemoteSetupCommand(
|
|
|
37
38
|
if (subcommand === 'setup') {
|
|
38
39
|
const command = getDefaultAcpAgentCommand();
|
|
39
40
|
const danger = ctx.platform.configManager.getCategory('danger');
|
|
41
|
+
const daemonEnabled = resolveDaemonEnabled(ctx.platform.configManager);
|
|
40
42
|
const lines = [
|
|
41
43
|
'Remote Setup Review',
|
|
42
44
|
` acp agent command: ${command.join(' ')}`,
|
|
43
|
-
` daemon enabled: ${
|
|
45
|
+
` daemon enabled: ${daemonEnabled ? 'yes' : 'no'}`,
|
|
44
46
|
` http listener enabled: ${danger.httpListener ? 'yes' : 'no'}`,
|
|
45
47
|
` remote runner contracts: ${remoteRegistry.listContracts().length}`,
|
|
46
48
|
` active acp connections: ${activeConnections.length}`,
|
|
@@ -48,7 +50,7 @@ export async function handleRemoteSetupCommand(
|
|
|
48
50
|
' guidance:',
|
|
49
51
|
' - set ACP_AGENT_CMD to override the spawned remote agent command',
|
|
50
52
|
' - use /remote env to export a reusable shell snippet',
|
|
51
|
-
' -
|
|
53
|
+
' - the daemon runs by default (daemon.enabled); enable danger.httpListener only when you actually need that remote surface',
|
|
52
54
|
];
|
|
53
55
|
if (args[1]?.toLowerCase() === 'export') {
|
|
54
56
|
const pathArg = args[2];
|
|
@@ -62,7 +64,7 @@ export async function handleRemoteSetupCommand(
|
|
|
62
64
|
writeFileSync(targetPath, `${JSON.stringify({
|
|
63
65
|
exportedAt: Date.now(),
|
|
64
66
|
acpAgentCommand: command,
|
|
65
|
-
daemonEnabled
|
|
67
|
+
daemonEnabled,
|
|
66
68
|
httpListenerEnabled: Boolean(danger.httpListener),
|
|
67
69
|
remoteRunnerContracts: remoteRegistry.listContracts().length,
|
|
68
70
|
}, null, 2)}\n`, 'utf-8');
|
|
@@ -384,9 +384,20 @@ export function registerSessionContentCommands(registry: CommandRegistry): void
|
|
|
384
384
|
},
|
|
385
385
|
});
|
|
386
386
|
|
|
387
|
+
// W6-C3 (Wave 6 core-verb pass, MEMORY fragmentation — worst-class
|
|
388
|
+
// collision #2): this command used to be named /memory, colliding with
|
|
389
|
+
// the agent's /memory (an alias that forwards to /recall, the durable
|
|
390
|
+
// cross-session MemoryStore) — same command name, two unrelated features.
|
|
391
|
+
// Renamed to /note (this feature manages EPHEMERAL, session-scoped sticky
|
|
392
|
+
// notes pinned across context compaction — an entirely different resource
|
|
393
|
+
// from /recall's durable records). /memory is now registered as an alias
|
|
394
|
+
// of /recall below (memory.ts), matching the agent, so the word means the
|
|
395
|
+
// same thing on both surfaces. See
|
|
396
|
+
// docs/decisions/2026-07-06-core-verb-spec.md (in the SDK repo) for the
|
|
397
|
+
// full writeup.
|
|
387
398
|
registry.register({
|
|
388
|
-
name: '
|
|
389
|
-
description: 'Manage session
|
|
399
|
+
name: 'note',
|
|
400
|
+
description: 'Manage session notes (pinned across context compaction)',
|
|
390
401
|
usage: '[list|add <text>|remove <id>]',
|
|
391
402
|
argsHint: '[list|add|remove]',
|
|
392
403
|
handler(args, ctx) {
|
|
@@ -394,26 +405,26 @@ export function registerSessionContentCommands(registry: CommandRegistry): void
|
|
|
394
405
|
if (sub === 'list' || args.length === 0) {
|
|
395
406
|
const memories = requireSessionMemoryStore(ctx).list();
|
|
396
407
|
ctx.print(memories.length === 0
|
|
397
|
-
? 'No session
|
|
398
|
-
: [`Session
|
|
408
|
+
? 'No session notes. Use !# prefix or /note add <text> to create one.'
|
|
409
|
+
: [`Session Notes (${memories.length}):`, ...memories.map(m => ` [${m.id}] ${m.text}`)].join('\n'));
|
|
399
410
|
} else if (sub === 'add') {
|
|
400
411
|
const text = args.slice(1).join(' ').trim();
|
|
401
412
|
if (!text) {
|
|
402
|
-
ctx.print('Usage: /
|
|
413
|
+
ctx.print('Usage: /note add <text>');
|
|
403
414
|
return;
|
|
404
415
|
}
|
|
405
416
|
const id = requireSessionMemoryStore(ctx).add(text);
|
|
406
|
-
ctx.print(`
|
|
417
|
+
ctx.print(`Note added: [${id}] ${text}`);
|
|
407
418
|
} else if (sub === 'remove') {
|
|
408
419
|
const id = args[1];
|
|
409
420
|
if (!id) {
|
|
410
|
-
ctx.print('Usage: /
|
|
421
|
+
ctx.print('Usage: /note remove <id>');
|
|
411
422
|
return;
|
|
412
423
|
}
|
|
413
424
|
const store = requireSessionMemoryStore(ctx);
|
|
414
|
-
ctx.print(store.remove(id) ? `
|
|
425
|
+
ctx.print(store.remove(id) ? `Note removed: [${id}]` : `Note not found: ${id}`);
|
|
415
426
|
} else {
|
|
416
|
-
ctx.print('Usage: /
|
|
427
|
+
ctx.print('Usage: /note [list|add <text>|remove <id>]\n /note — list all session notes\n /note list — list all session notes\n /note add <text> — add a note without sending a message\n /note remove <id> — remove a specific note');
|
|
417
428
|
}
|
|
418
429
|
},
|
|
419
430
|
});
|
|
@@ -26,16 +26,28 @@ export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry):
|
|
|
26
26
|
registry.register({
|
|
27
27
|
name: 'settings-sync',
|
|
28
28
|
aliases: ['settingssync'],
|
|
29
|
-
description: '
|
|
30
|
-
usage: '[review|
|
|
29
|
+
description: 'Open the settings sync modal (bare); review posture, export/import bundles, or resolve conflicts by subcommand',
|
|
30
|
+
usage: '[panel|report|review|show <key>|staged|conflicts|resolve <key> <local|synced>|failures|rollback-history|export <path>|inspect <path>|pull <path>|push <path>|lock <key> <source> <reason...>|unlock <key>] — bare opens the modal',
|
|
31
31
|
handler(args, ctx) {
|
|
32
32
|
const shellPaths = requireShellPaths(ctx);
|
|
33
33
|
const controlPlaneConfigDir = ctx.platform.configManager.getControlPlaneConfigDir();
|
|
34
|
-
const sub = (args[0] ?? '
|
|
34
|
+
const sub = (args[0] ?? '').toLowerCase();
|
|
35
|
+
// DEBT-5 item 3 (report-vs-modal front doors): settings-sync has a full
|
|
36
|
+
// modal surface (settings-sync-modal), so the bare command now opens it
|
|
37
|
+
// — the old bare/`review` transcript report moved to an explicit
|
|
38
|
+
// `report` subcommand (scriptability preserved: /settings-sync report).
|
|
39
|
+
if (sub === '') {
|
|
40
|
+
ctx.openModal?.('settings-sync-modal');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
35
43
|
if (sub === 'panel' || sub === 'open') {
|
|
36
44
|
ctx.openModal?.('settings-sync-modal'); // W6.1: settings-sync panel -> config modal
|
|
37
45
|
return;
|
|
38
46
|
}
|
|
47
|
+
if (sub === 'report' || sub === 'review') {
|
|
48
|
+
ctx.print(formatSettingsControlPlaneReview(ctx.platform.configManager).join('\n'));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
39
51
|
if (sub === 'show') {
|
|
40
52
|
const key = args[1] as ConfigKey | undefined;
|
|
41
53
|
if (!key || !CONFIG_KEYS.has(key)) {
|
|
@@ -163,7 +163,10 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
|
|
|
163
163
|
{ id: '/quit', label: '/quit', detail: 'Exit', category: 'Tools & System' },
|
|
164
164
|
{ id: '/wq', label: '/wq', detail: 'Commit all git changes and then exit', category: 'Tools & System' },
|
|
165
165
|
];
|
|
166
|
-
|
|
166
|
+
// UX-C: every item here is a command, and picking one RUNS it — label
|
|
167
|
+
// the verb "Run" (matching the slash-command palette) instead of the
|
|
168
|
+
// generic "Select" default.
|
|
169
|
+
ctx.openSelection('Help — Commands', items, { allowSearch: true, primaryVerbLabel: 'Run' }, (result) => {
|
|
167
170
|
if (!result) return;
|
|
168
171
|
const command = result.item.id;
|
|
169
172
|
if (command.startsWith('/')) {
|
|
@@ -294,6 +297,12 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
|
|
|
294
297
|
handler(_args, ctx) {
|
|
295
298
|
ctx.session.runtime.debugMode = !ctx.session.runtime.debugMode;
|
|
296
299
|
ctx.print(`Debug mode: ${ctx.session.runtime.debugMode ? 'ON' : 'OFF'}`);
|
|
300
|
+
// Honest quiet counter for direct terminal writes the guard intercepted
|
|
301
|
+
// (routed here instead of spamming the transcript). (UX-B item 1a.)
|
|
302
|
+
const intercepted = ctx.session.runtime.terminalWritesIntercepted ?? 0;
|
|
303
|
+
if (intercepted > 0) {
|
|
304
|
+
ctx.print(`Terminal writes intercepted this session: ${intercepted} (details in the activity log)`);
|
|
305
|
+
}
|
|
297
306
|
},
|
|
298
307
|
});
|
|
299
308
|
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
21
|
|
|
22
22
|
import { AdaptivePlanner } from '@pellux/goodvibes-sdk/platform/core';
|
|
23
|
-
import type { PhaseKind, PhaseRole, WorkItemState, Workstream } from '@pellux/goodvibes-sdk/platform/orchestration';
|
|
24
|
-
import type { WorkstreamCommandService, WorkstreamDraft } from '../../runtime/workstream-services.ts';
|
|
23
|
+
import type { CreateWorkstreamInput, PhaseKind, PhaseRole, WorkItem, WorkItemState, Workstream, WorkstreamIsolation } from '@pellux/goodvibes-sdk/platform/orchestration';
|
|
24
|
+
import type { WorkstreamCommandService, WorkstreamDraft, WorkstreamDraftProvenance } from '../../runtime/workstream-services.ts';
|
|
25
25
|
import type { CommandContext, CommandRegistry } from '../command-registry.ts';
|
|
26
26
|
|
|
27
27
|
// ---------------------------------------------------------------------------
|
|
@@ -70,27 +70,140 @@ function formatPhaseLabel(phase: { readonly kind: PhaseKind; readonly role: Phas
|
|
|
70
70
|
return `${phase.kind} — ${phase.role}`;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Extracts an optional `--isolation shared|worktree` flag from anywhere in
|
|
75
|
+
* `args` (create/edit both accept it ahead of, or interleaved with, the task
|
|
76
|
+
* text). Returns the flag stripped out so the remaining tokens are the task
|
|
77
|
+
* text exactly as before this flag existed. An unrecognized value is a hard
|
|
78
|
+
* error (never silently ignored or defaulted) — a typo'd isolation mode
|
|
79
|
+
* must never quietly launch in the wrong one.
|
|
80
|
+
*/
|
|
81
|
+
function extractIsolationFlag(args: readonly string[]): { isolation?: WorkstreamIsolation; rest: string[]; error?: string } {
|
|
82
|
+
const rest: string[] = [];
|
|
83
|
+
let isolation: WorkstreamIsolation | undefined;
|
|
84
|
+
for (let i = 0; i < args.length; i++) {
|
|
85
|
+
if (args[i] === '--isolation') {
|
|
86
|
+
const value = args[i + 1];
|
|
87
|
+
if (value !== 'shared' && value !== 'worktree') {
|
|
88
|
+
return { rest, error: `--isolation must be "shared" or "worktree" (got: ${value ?? '<nothing>'})` };
|
|
89
|
+
}
|
|
90
|
+
isolation = value;
|
|
91
|
+
i += 1; // consume the value token too
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
rest.push(args[i]!);
|
|
95
|
+
}
|
|
96
|
+
return { isolation, rest };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Per-item merge-state text for `/workstream status` (worktree isolation
|
|
101
|
+
* only — see formatItemMergeState's caller). Distinct from `item.state` (the
|
|
102
|
+
* pipeline verdict): an item can be terminally 'passed' while its branch is
|
|
103
|
+
* still 'merge pending' in the integration lane, or stuck at
|
|
104
|
+
* 'merge-conflict' with its worktree deliberately kept for inspection.
|
|
105
|
+
*/
|
|
106
|
+
function formatItemMergeState(item: WorkItem): string {
|
|
107
|
+
switch (item.mergeState) {
|
|
108
|
+
case 'merged':
|
|
109
|
+
return item.mergeHash ? `merged ${shortId(item.mergeHash)}` : 'merged (no changes)';
|
|
110
|
+
case 'conflict':
|
|
111
|
+
return 'merge-conflict (worktree kept for inspection)';
|
|
112
|
+
case 'pending':
|
|
113
|
+
return 'merge pending';
|
|
114
|
+
case 'n-a':
|
|
115
|
+
default:
|
|
116
|
+
return item.worktreeKept ? 'worktree kept' : 'not yet integrated';
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Per-item dependency truth for `/workstream status` (BIG-3 item 2). A
|
|
122
|
+
* dependency-blocked item shows its honest, engine-set reason verbatim
|
|
123
|
+
* ('waiting on: X' or 'dependency failed: X'); an item that HAS dependencies
|
|
124
|
+
* but is no longer blocked shows the static 'after: X, Y' provenance so the
|
|
125
|
+
* ordering constraint stays visible even once satisfied. An item with no
|
|
126
|
+
* dependencies contributes nothing.
|
|
127
|
+
*/
|
|
128
|
+
function formatItemDependencyNote(item: WorkItem, ws: Workstream): string {
|
|
129
|
+
if (item.state === 'blocked-dependency' && item.blockedReason) return ` — ${item.blockedReason}`;
|
|
130
|
+
if (item.dependsOn && item.dependsOn.length > 0) {
|
|
131
|
+
const titleById = new Map(ws.items.map((i) => [i.id, i.title]));
|
|
132
|
+
return ` — after: ${item.dependsOn.map((d) => titleById.get(d) ?? shortId(d)).join(', ')}`;
|
|
133
|
+
}
|
|
134
|
+
return '';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Compact item + dependency graph for the draft-approval view (BIG-3 item 4):
|
|
139
|
+
* items in ordinal order (the spec preserves the proposal's order), each with
|
|
140
|
+
* an honest text 'after: X, Y' clause when it depends on siblings — no fake DAG
|
|
141
|
+
* art. Dependency ids are resolved back to titles from the spec itself.
|
|
142
|
+
*/
|
|
143
|
+
function formatDraftItems(spec: CreateWorkstreamInput): string[] {
|
|
144
|
+
const titleById = new Map(spec.items.map((it) => [it.id ?? it.title, it.title] as const));
|
|
145
|
+
return spec.items.map((it, i) => {
|
|
146
|
+
const deps = it.dependsOn ?? [];
|
|
147
|
+
const after = deps.length > 0 ? ` (after: ${deps.map((d) => titleById.get(d) ?? d).join(', ')})` : '';
|
|
148
|
+
return ` ${i + 1}. ${it.title}${after}`;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
73
152
|
function summarizeItemStates(ws: Workstream): string {
|
|
74
153
|
const counts = new Map<string, number>();
|
|
75
154
|
for (const item of ws.items) counts.set(item.state, (counts.get(item.state) ?? 0) + 1);
|
|
76
155
|
return Array.from(counts.entries()).map(([state, n]) => `${n} ${state}`).join(', ') || 'no items';
|
|
77
156
|
}
|
|
78
157
|
|
|
79
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* Honest one-line provenance for how the goal was decomposed. Three shapes,
|
|
160
|
+
* mirroring the SDK decomposition service's outcomes:
|
|
161
|
+
* - a planning agent decomposed it (with item count + cost/tokens + elapsed)
|
|
162
|
+
* - the heuristic path ran because the agent path failed (fallback + reason)
|
|
163
|
+
* - the heuristic path ran deliberately (configured, or the gate declined)
|
|
164
|
+
*/
|
|
165
|
+
function formatProvenance(p: WorkstreamDraftProvenance): string {
|
|
166
|
+
if (p.kind === 'agent') {
|
|
167
|
+
const bits: string[] = [`${p.itemCount} item${p.itemCount === 1 ? '' : 's'}`];
|
|
168
|
+
if (p.agentCostUsd !== undefined) bits.push(`$${p.agentCostUsd.toFixed(4)}`);
|
|
169
|
+
else if (p.agentTokens !== undefined) bits.push(`${p.agentTokens} tok`);
|
|
170
|
+
if (p.elapsedMs !== undefined) bits.push(`${Math.round(p.elapsedMs / 1000)}s`);
|
|
171
|
+
return `Decomposition: planning agent (${bits.join(', ')})`;
|
|
172
|
+
}
|
|
173
|
+
if (p.kind === 'fallback') {
|
|
174
|
+
return `Decomposition: heuristic (agent fallback: ${p.fallbackReason ?? 'unknown'})`;
|
|
175
|
+
}
|
|
176
|
+
if (p.kind === 'gate-declined') {
|
|
177
|
+
return 'Decomposition: heuristic (single item; planner declined to decompose)';
|
|
178
|
+
}
|
|
179
|
+
return 'Decomposition: heuristic (configured)';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The engine's own PlanProposal is deliberately not rendered as the launchable spec — see workstream-services.ts's buildSpec doc. This renders the REAL launchable spec (so the proposal and the launch are always the same plan) plus an honest provenance line describing how the goal was decomposed. */
|
|
80
183
|
function renderDraftProposal(draft: WorkstreamDraft): string {
|
|
81
184
|
const lines: string[] = [];
|
|
82
185
|
lines.push(`Workstream proposal ${draft.id} — "${draft.task}"`);
|
|
186
|
+
lines.push(`Isolation: ${draft.spec.isolation ?? 'shared (default)'}`);
|
|
83
187
|
lines.push(
|
|
84
188
|
`Planner: strategy=${draft.gate.strategy} (${draft.gate.reasonCode}) — ${AdaptivePlanner.explainReasonCode(draft.gate.reasonCode)}`,
|
|
85
189
|
);
|
|
190
|
+
lines.push(formatProvenance(draft.provenance));
|
|
191
|
+
// Honest mapping boundary (BIG-3 item 4): a multi-item proposal launches as
|
|
192
|
+
// the REAL dependency-scheduled workstream (fromPlanProposal); a single-item
|
|
193
|
+
// one launches as the compat engineer→review chain (fromChainSpec). State
|
|
194
|
+
// which, so the preview never overstates what launch will do.
|
|
195
|
+
const multiItem = draft.spec.items.length > 1;
|
|
196
|
+
lines.push(
|
|
197
|
+
multiItem
|
|
198
|
+
? `Mapping: multi-item plan — ${draft.spec.items.length} items run the engineer→review pipeline, dependency-scheduled.`
|
|
199
|
+
: 'Mapping: single-item compat chain (engineer→review) — no multi-item structure to schedule.',
|
|
200
|
+
);
|
|
86
201
|
lines.push('Phases:');
|
|
87
202
|
draft.spec.phases.forEach((phase, i) => {
|
|
88
203
|
lines.push(` ${i + 1}. ${formatPhaseLabel(phase)} (capacity ${phase.capacity})`);
|
|
89
204
|
});
|
|
90
|
-
lines.push('Work items:');
|
|
91
|
-
|
|
92
|
-
lines.push(` - ${item.title}`);
|
|
93
|
-
}
|
|
205
|
+
lines.push('Work items (ordinal order):');
|
|
206
|
+
lines.push(...formatDraftItems(draft.spec));
|
|
94
207
|
lines.push(
|
|
95
208
|
draft.approved
|
|
96
209
|
? `Approved. Launch with: /workstream launch ${draft.id}`
|
|
@@ -107,15 +220,42 @@ function renderDraftProposal(draft: WorkstreamDraft): string {
|
|
|
107
220
|
}
|
|
108
221
|
|
|
109
222
|
function renderWorkstreamStatus(ws: Workstream): string {
|
|
223
|
+
const isolated = ws.isolation === 'worktree';
|
|
110
224
|
const lines: string[] = [];
|
|
111
225
|
lines.push(`Workstream ${ws.id} — "${ws.title}"`);
|
|
226
|
+
lines.push(`Isolation: ${ws.isolation ?? 'shared'}`);
|
|
227
|
+
// Origin provenance (BIG-3 item 1) — only present on workstreams assembled
|
|
228
|
+
// from a decomposition proposal; absent for compat/authored ones.
|
|
229
|
+
if (ws.provenance) {
|
|
230
|
+
const pv = ws.provenance;
|
|
231
|
+
const bits: string[] = [];
|
|
232
|
+
if (pv.decomposedBy) bits.push(`${pv.decomposedBy}-decomposed`);
|
|
233
|
+
if (pv.proposalId) bits.push(`plan ${pv.proposalId}`);
|
|
234
|
+
if (pv.strategy) bits.push(pv.strategy);
|
|
235
|
+
if (bits.length > 0) lines.push(`Origin: ${bits.join(', ')}`);
|
|
236
|
+
}
|
|
112
237
|
lines.push('Phases:');
|
|
113
238
|
for (const phase of ws.phases) {
|
|
114
239
|
lines.push(` [${phase.ordinal}] ${formatPhaseLabel(phase)} (capacity ${phase.capacity})`);
|
|
115
240
|
}
|
|
116
241
|
lines.push('Items:');
|
|
117
242
|
for (const item of ws.items) {
|
|
118
|
-
|
|
243
|
+
const mergeNote = isolated ? ` — ${formatItemMergeState(item)}` : '';
|
|
244
|
+
const depNote = formatItemDependencyNote(item, ws);
|
|
245
|
+
lines.push(` ${shortId(item.id)} [${item.state}] ${item.title} — phase: ${item.currentPhaseId ?? '—'}${depNote}${mergeNote}`);
|
|
246
|
+
}
|
|
247
|
+
if (isolated) {
|
|
248
|
+
// Honest terminal-summary truth (never inferred from item.state alone —
|
|
249
|
+
// an item can be terminally 'passed' with its branch still unmerged):
|
|
250
|
+
// an item counts as unmerged the instant it enters the integration lane
|
|
251
|
+
// (mergeState 'pending') and stays counted through a conflict or any
|
|
252
|
+
// KEPT worktree, until a clean merge clears it.
|
|
253
|
+
const unmerged = ws.items.filter((item) => item.mergeState === 'pending' || item.mergeState === 'conflict' || item.worktreeKept);
|
|
254
|
+
lines.push(
|
|
255
|
+
unmerged.length > 0
|
|
256
|
+
? `Unmerged items: ${unmerged.length} (${unmerged.map((item) => shortId(item.id)).join(', ')}) — this run is NOT fully integrated yet.`
|
|
257
|
+
: 'Unmerged items: none — every terminated item is merged (or had nothing to merge).',
|
|
258
|
+
);
|
|
119
259
|
}
|
|
120
260
|
return lines.join('\n');
|
|
121
261
|
}
|
|
@@ -167,8 +307,8 @@ export function registerWorkstreamRuntimeCommands(registry: CommandRegistry): vo
|
|
|
167
307
|
registry.register({
|
|
168
308
|
name: 'workstream',
|
|
169
309
|
description: 'Author and oversee multi-phase agent workstreams (orchestration engine)',
|
|
170
|
-
usage: 'create <task...> | list | status [id] | insert-phase <id> <description...> | approve <id> | edit <id> <task...> | launch <id> | cancel <id>',
|
|
171
|
-
argsHint: 'create <task> | list | status [id] | approve | edit | launch | cancel',
|
|
310
|
+
usage: 'create [--isolation shared|worktree] <task...> | list | status [id] | insert-phase <id> <description...> | approve <id> | edit <id> [--isolation shared|worktree] <task...> | launch <id> | cancel <id>',
|
|
311
|
+
argsHint: 'create [--isolation worktree] <task> | list | status [id] | approve | edit | launch | cancel',
|
|
172
312
|
handler: async (args, ctx: CommandContext) => {
|
|
173
313
|
const service = ctx.session.workstreamEngine;
|
|
174
314
|
if (!service) {
|
|
@@ -184,12 +324,17 @@ export function registerWorkstreamRuntimeCommands(registry: CommandRegistry): vo
|
|
|
184
324
|
}
|
|
185
325
|
|
|
186
326
|
if (sub === 'create') {
|
|
187
|
-
const
|
|
327
|
+
const { isolation, rest, error } = extractIsolationFlag(args.slice(1));
|
|
328
|
+
if (error) {
|
|
329
|
+
ctx.print(`Usage: /workstream create [--isolation shared|worktree] <task...>\n${error}`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const task = rest.join(' ').trim();
|
|
188
333
|
if (!task) {
|
|
189
|
-
ctx.print('Usage: /workstream create <task...>');
|
|
334
|
+
ctx.print('Usage: /workstream create [--isolation shared|worktree] <task...>');
|
|
190
335
|
return;
|
|
191
336
|
}
|
|
192
|
-
const draft = service.proposeDraft(task);
|
|
337
|
+
const draft = await service.proposeDraft(task, isolation);
|
|
193
338
|
ctx.print(renderDraftProposal(draft));
|
|
194
339
|
return;
|
|
195
340
|
}
|
|
@@ -255,12 +400,17 @@ export function registerWorkstreamRuntimeCommands(registry: CommandRegistry): vo
|
|
|
255
400
|
|
|
256
401
|
if (sub === 'edit') {
|
|
257
402
|
const id = args[1];
|
|
258
|
-
const
|
|
403
|
+
const { isolation, rest, error } = extractIsolationFlag(args.slice(2));
|
|
404
|
+
if (error) {
|
|
405
|
+
ctx.print(`Usage: /workstream edit <id> [--isolation shared|worktree] <new task...>\n${error}`);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const task = rest.join(' ').trim();
|
|
259
409
|
if (!id || !task) {
|
|
260
|
-
ctx.print('Usage: /workstream edit <id> <new task...>');
|
|
410
|
+
ctx.print('Usage: /workstream edit <id> [--isolation shared|worktree] <new task...>');
|
|
261
411
|
return;
|
|
262
412
|
}
|
|
263
|
-
const draft = service.editDraft(id, task);
|
|
413
|
+
const draft = await service.editDraft(id, task, isolation);
|
|
264
414
|
if (!draft) {
|
|
265
415
|
ctx.print(draftNotFoundMessage(service, id));
|
|
266
416
|
return;
|
|
@@ -324,12 +474,12 @@ export function registerWorkstreamRuntimeCommands(registry: CommandRegistry): vo
|
|
|
324
474
|
|
|
325
475
|
ctx.print(
|
|
326
476
|
'Usage:\n'
|
|
327
|
-
+ ' /workstream create <task...>\n'
|
|
477
|
+
+ ' /workstream create [--isolation shared|worktree] <task...>\n'
|
|
328
478
|
+ ' /workstream list\n'
|
|
329
479
|
+ ' /workstream status [id]\n'
|
|
330
480
|
+ ' /workstream insert-phase <id> <description...>\n'
|
|
331
481
|
+ ' /workstream approve <id>\n'
|
|
332
|
-
+ ' /workstream edit <id> <new task...>\n'
|
|
482
|
+
+ ' /workstream edit <id> [--isolation shared|worktree] <new task...>\n'
|
|
333
483
|
+ ' /workstream launch <id>\n'
|
|
334
484
|
+ ' /workstream cancel <id>',
|
|
335
485
|
);
|
|
@@ -89,6 +89,17 @@ export interface ConfigModalActionContext {
|
|
|
89
89
|
* services surface jumping to the subscription surface). Swaps the active
|
|
90
90
|
* surface in place — the same seam ctx.openModal uses. */
|
|
91
91
|
readonly openModal?: (name: string) => void;
|
|
92
|
+
/**
|
|
93
|
+
* Submit text to the chat/model as a real user turn — the same composer
|
|
94
|
+
* submission seam `handleUserInput` uses (threaded from CommandContext.submitInput).
|
|
95
|
+
* Generic across surfaces (any modal may hand free-form input to the model).
|
|
96
|
+
*
|
|
97
|
+
* ORDERING GUARD: a turn must never start while a modal owns the keyboard —
|
|
98
|
+
* that is the modal-liveness hazard (a live turn painting under a modal that
|
|
99
|
+
* still captures keys). Callers MUST `close()` the modal BEFORE invoking
|
|
100
|
+
* `submitInput`. The planning modal's free-form submit follows this ordering.
|
|
101
|
+
*/
|
|
102
|
+
readonly submitInput?: (text: string) => void;
|
|
92
103
|
/** Request a re-render. */
|
|
93
104
|
readonly requestRender: () => void;
|
|
94
105
|
/** Set the modal's transient status line (e.g. a result or error message). */
|
|
@@ -102,7 +113,10 @@ export interface ConfigModalAction {
|
|
|
102
113
|
/**
|
|
103
114
|
* Trigger key: a single printable char ('r', 'd') or a named key ('enter').
|
|
104
115
|
* Must not collide with the host-reserved nav keys (up/down/left/right/tab/
|
|
105
|
-
* j/k/escape) — those are consumed by the host before actions are
|
|
116
|
+
* j/k/escape/'/') — those are consumed by the host before actions are
|
|
117
|
+
* consulted. '/' arms the host's type-to-filter (DEBT-5 item 1); while
|
|
118
|
+
* filtering, EVERY printable key (not just j/k) is captured by the query
|
|
119
|
+
* instead of firing an action — see handleConfigModalToken.
|
|
106
120
|
*/
|
|
107
121
|
readonly key: string;
|
|
108
122
|
/** Action id passed to `onAction`. */
|