pi-advisor-flow 0.7.0 → 0.8.1-dev.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +27 -18
  2. package/README.md +9 -8
  3. package/dist/index.js +1968 -1081
  4. package/extensions/index.ts +2 -1
  5. package/package.json +48 -35
  6. package/src/attachments.ts +11 -10
  7. package/src/child-session.ts +15 -0
  8. package/src/commands/activation-preparation.ts +1 -0
  9. package/src/commands/activation.ts +12 -3
  10. package/src/commands/lifecycle.ts +7 -1
  11. package/src/commands/manual-command.ts +7 -1
  12. package/src/commands/manual-consultation.ts +102 -92
  13. package/src/commands/manual-progress.ts +7 -5
  14. package/src/commands/model-commands.ts +1 -1
  15. package/src/commands/model-options.ts +14 -3
  16. package/src/commands/model-picker.ts +1 -0
  17. package/src/commands/registration.ts +1 -0
  18. package/src/commands/renderers.ts +67 -55
  19. package/src/commands/runtime.ts +16 -14
  20. package/src/commands/settings-commands.ts +10 -4
  21. package/src/commands/settings-persistence.ts +16 -2
  22. package/src/commands/types.ts +1 -0
  23. package/src/commands.ts +0 -1
  24. package/src/config/args.ts +1 -1
  25. package/src/config/defaults.ts +8 -1
  26. package/src/config/schema.ts +42 -17
  27. package/src/config/state.ts +12 -3
  28. package/src/config/storage.ts +34 -23
  29. package/src/config/types.ts +16 -3
  30. package/src/config/validation.ts +33 -21
  31. package/src/config.ts +3 -0
  32. package/src/content-utils.ts +24 -4
  33. package/src/conversation.ts +16 -15
  34. package/src/git.ts +10 -3
  35. package/src/herdr-block.ts +83 -0
  36. package/src/herdr-shared.ts +108 -0
  37. package/src/herdr.ts +30 -192
  38. package/src/jev/client.ts +75 -85
  39. package/src/jev/failure.ts +19 -0
  40. package/src/jev/key-store.ts +26 -11
  41. package/src/jev/ledger.ts +58 -64
  42. package/src/jev/questions.ts +26 -23
  43. package/src/jev/state.ts +15 -12
  44. package/src/jev/transport.ts +8 -3
  45. package/src/model-stream.ts +35 -37
  46. package/src/outcomes.ts +15 -11
  47. package/src/pi-settings.ts +4 -9
  48. package/src/preferences.ts +4 -2
  49. package/src/redaction.ts +10 -12
  50. package/src/scout-context.ts +59 -60
  51. package/src/scout-curation.ts +4 -13
  52. package/src/scout-groups.ts +178 -175
  53. package/src/scout-protocol.ts +74 -70
  54. package/src/scout-reconstruct.ts +1 -1
  55. package/src/scout.ts +128 -77
  56. package/src/session-state.ts +64 -49
  57. package/src/tool-result-cap.ts +18 -17
  58. package/src/tools/consult-context.ts +7 -15
  59. package/src/tools/consultation.ts +14 -4
  60. package/src/tools/gate-policy.ts +1 -0
  61. package/src/tools/gate-protocol.ts +36 -23
  62. package/src/tools/jev-filter.ts +43 -41
  63. package/src/tools/jev-turn-gate.ts +29 -9
  64. package/src/tools/loop-gate.ts +31 -20
  65. package/src/tools/model-access.ts +46 -0
  66. package/src/tools/prompts.ts +20 -25
  67. package/src/tools/register-ask-advisor.ts +36 -16
  68. package/src/tools/register-lifecycle.ts +22 -0
  69. package/src/tools/register-outcome.ts +2 -0
  70. package/src/tools/register-renderers.ts +146 -128
  71. package/src/tools/registration.ts +12 -14
  72. package/src/tools/render-advisor-result.ts +35 -22
  73. package/src/tools/render-common.ts +16 -19
  74. package/src/tools/scout-status.ts +42 -27
  75. package/src/tools/types.ts +1 -0
  76. package/src/tools.ts +0 -1
  77. package/src/ui/jev-setup-submenu.ts +65 -59
  78. package/src/ui/manual-dialog-render.ts +1 -0
  79. package/src/ui/manual-dialog.ts +39 -43
  80. package/src/ui/masked-input.ts +5 -11
  81. package/src/ui/model-multi-selector.ts +9 -0
  82. package/src/ui/model-selector-adapter.ts +31 -0
  83. package/src/ui/model-selector.ts +14 -161
  84. package/src/ui/searchable-model-list.ts +230 -0
  85. package/src/ui/settings-formatting.ts +24 -14
  86. package/src/ui/settings-items.ts +38 -1
  87. package/src/ui/settings-list-adapter.ts +15 -12
  88. package/src/ui/settings-mutations.ts +157 -70
  89. package/src/ui/settings-selector.ts +18 -9
  90. package/src/ui/text-setting-submenu.ts +3 -6
  91. package/src/ui/types.ts +14 -0
  92. package/src/ui.ts +0 -1
  93. package/src/usage.ts +26 -19
@@ -1,9 +1,11 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
2
3
  import { appendOutcome } from "../outcomes.ts";
3
4
  import type { AdvisorSessionState } from "../session-state.ts";
4
5
  import { consultAdvisor, runAdvisorGate } from "./consultation.ts";
5
6
  import { screenConsultation } from "./jev-filter.ts";
6
7
  import { registerJevTurnGate } from "./jev-turn-gate.ts";
8
+ import type { JevTurnGateRegistration } from "./jev-turn-gate.ts";
7
9
  import { registerAskAdvisorTool } from "./register-ask-advisor.ts";
8
10
  import { registerToolLifecycle } from "./register-lifecycle.ts";
9
11
  import { registerOutcomeTool } from "./register-outcome.ts";
@@ -35,18 +37,14 @@ export const registerAdvisorTool = (
35
37
  registerToolLifecycle(registration);
36
38
  registerAskAdvisorTool(registration);
37
39
  registerOutcomeTool(registration);
38
- registerJevTurnGate(
39
- (
40
- event: Parameters<ExtensionAPI["on"]>[0],
41
- handler: (event: unknown, ctx: never) => unknown
42
- ) => pi.on(event, handler as never),
43
- {
44
- activeTools: () => pi.getActiveTools(),
45
- consult: registration.consult,
46
- ...(dependencies.turnGateDeps ? { deps: dependencies.turnGateDeps } : {}),
47
- send: (message) =>
48
- pi.sendMessage(message as never, { deliverAs: "steer" } as never),
49
- session,
50
- }
51
- );
40
+ const turnGate: JevTurnGateRegistration = {
41
+ activeTools: () => pi.getActiveTools(),
42
+ consult: registration.consult,
43
+ send: (message) => pi.sendMessage(message, { deliverAs: "steer" }),
44
+ session,
45
+ };
46
+ if (dependencies.turnGateDeps) {
47
+ turnGate.deps = dependencies.turnGateDeps;
48
+ }
49
+ registerJevTurnGate((event, handler) => pi.on(event, handler), turnGate);
52
50
  };
@@ -1,10 +1,11 @@
1
- import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
- import {
3
- getMarkdownTheme,
4
- type Theme,
5
- type ToolRenderResultOptions,
1
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ AgentToolResult,
4
+ Theme,
5
+ ToolRenderResultOptions,
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import { Box, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
8
+
8
9
  import { getAdvisorSettings } from "../config/state.ts";
9
10
  import { textFrom } from "../conversation.ts";
10
11
  import { formatAdvisorUsage } from "../usage.ts";
@@ -116,6 +117,33 @@ const renderPartialAdvisorResult = (
116
117
  }
117
118
  };
118
119
 
120
+ const thinkingPreview = (details: AdvisorToolDetails | undefined) =>
121
+ details?.thinking?.trim()
122
+ ? `${details.thinking.slice(0, 300)}${details.thinking.length > 300 ? "…" : ""}`
123
+ : "";
124
+
125
+ const finalResultLines = (
126
+ details: AdvisorToolDetails | undefined,
127
+ advice: string,
128
+ theme: Theme
129
+ ): string[] => {
130
+ const lines = [renderAdvisorResponseHeader(hasSoundVerdict(advice), theme)];
131
+ if (details?.advisor) {
132
+ lines.push(theme.fg("dim", ` ${details.advisor}`));
133
+ }
134
+ if (getAdvisorSettings().showUsageDetails) {
135
+ const usage = formatAdvisorUsage(details?.usage);
136
+ if (usage) {
137
+ lines.push(theme.fg("dim", ` Usage: ${usage}`));
138
+ }
139
+ }
140
+ const attachments = attachmentLabels(details);
141
+ if (attachments.length) {
142
+ lines.push(theme.fg("dim", ` ${attachments.join(" · ")}`));
143
+ }
144
+ return lines;
145
+ };
146
+
119
147
  const renderFinalAdvisorResult = (
120
148
  box: Box,
121
149
  result: AgentToolResult<AdvisorToolDetails>,
@@ -144,23 +172,8 @@ const renderFinalAdvisorResult = (
144
172
  return;
145
173
  }
146
174
  const advice = details?.text || textFrom(result.content);
147
- const lines = [renderAdvisorResponseHeader(hasSoundVerdict(advice), theme)];
148
- if (details?.advisor) {
149
- lines.push(theme.fg("dim", ` ${details.advisor}`));
150
- }
151
- if (getAdvisorSettings().showUsageDetails) {
152
- const usage = formatAdvisorUsage(details?.usage);
153
- if (usage) {
154
- lines.push(theme.fg("dim", ` Usage: ${usage}`));
155
- }
156
- }
157
- const attachments = attachmentLabels(details);
158
- if (attachments.length) {
159
- lines.push(theme.fg("dim", ` ${attachments.join(" · ")}`));
160
- }
161
- const thinking = details?.thinking?.trim()
162
- ? `${details.thinking.slice(0, 300)}${details.thinking.length > 300 ? "…" : ""}`
163
- : "";
175
+ const thinking = thinkingPreview(details);
176
+ const lines = finalResultLines(details, advice, theme);
164
177
  const displayAdvice = advice || "(Advisor returned no advice.)";
165
178
  box.addChild(new Text(lines.join("\n"), 0, 0));
166
179
  if (thinking) {
@@ -1,12 +1,14 @@
1
- import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
1
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
3
  import {
3
4
  Box,
4
- type Component,
5
5
  Markdown,
6
6
  Text,
7
7
  truncateToWidth,
8
8
  visibleWidth,
9
9
  } from "@earendil-works/pi-tui";
10
+ import type { Component } from "@earendil-works/pi-tui";
11
+
10
12
  import { advisorCollapseResponsesRef } from "../config/state.ts";
11
13
  import { piHideThinkingEnabled } from "../pi-settings.ts";
12
14
 
@@ -27,6 +29,9 @@ const THINKING_PREFIX = " ";
27
29
  const THINKING_PREFIX_WIDTH = visibleWidth(THINKING_PREFIX);
28
30
  export type ThinkingTheme = Pick<Theme, "fg">;
29
31
 
32
+ /** Shared no-op for interface-required methods that manage no state. */
33
+ export const noop = (): void => undefined;
34
+
30
35
  /** Renders nested-model thinking as Markdown with the compact speech-bubble cue; the prefix is added after parsing so it cannot change block syntax. */
31
36
  class ThinkingMarkdown implements Component {
32
37
  private readonly markdown: Markdown;
@@ -59,28 +64,20 @@ class ThinkingMarkdown implements Component {
59
64
  }
60
65
 
61
66
  /** Collapsed placeholder shown when Pi's hide_thinking setting is on. */
62
- class HiddenThinkingLabel implements Component {
63
- private readonly label: string;
64
-
65
- constructor(theme: ThinkingTheme) {
66
- this.label = theme.fg("thinkingText", `${THINKING_PREFIX}Thinking…`);
67
- }
68
-
69
- render(): string[] {
70
- return [this.label];
71
- }
72
-
73
- invalidate(): void {
74
- // The collapsed label is static.
75
- }
76
- }
67
+ const hiddenThinkingLabel = (theme: ThinkingTheme): Component => {
68
+ const label = theme.fg("thinkingText", `${THINKING_PREFIX}Thinking…`);
69
+ return {
70
+ invalidate: noop,
71
+ render: () => [label],
72
+ };
73
+ };
77
74
 
78
75
  export const renderThinkingMarkdown = (
79
76
  thinking: string,
80
77
  theme: ThinkingTheme
81
78
  ): Component =>
82
79
  piHideThinkingEnabled()
83
- ? new HiddenThinkingLabel(theme)
80
+ ? hiddenThinkingLabel(theme)
84
81
  : new ThinkingMarkdown(thinking, theme);
85
82
 
86
83
  export const resolveAdvisorRequest = (question?: string) =>
@@ -107,7 +104,7 @@ export const renderAdvisorCallBox = (
107
104
 
108
105
  const COLLAPSED_ADVICE_LINES = 12;
109
106
  // The system prompt requires this exact first line, so the match is exact too.
110
- const SOUND_VERDICT = /^Verdict:\s*sound$/;
107
+ const SOUND_VERDICT = /^Verdict:\s*sound$/u;
111
108
 
112
109
  export const hasSoundVerdict = (advice: string) =>
113
110
  SOUND_VERDICT.test(
@@ -3,7 +3,9 @@ import type {
3
3
  ExtensionContext,
4
4
  Theme,
5
5
  } from "@earendil-works/pi-coding-agent";
6
- import { type Box, Text } from "@earendil-works/pi-tui";
6
+ import { Text } from "@earendil-works/pi-tui";
7
+ import type { Box } from "@earendil-works/pi-tui";
8
+
7
9
  import { executorRef, getAdvisorSettings } from "../config/state.ts";
8
10
  import type { ScoutLifecycleEvent } from "../scout.ts";
9
11
  import { formatAdvisorUsage, snapshotAdvisorUsage } from "../usage.ts";
@@ -142,6 +144,43 @@ const scoutTitle = (scout: ScoutToolDetails, frame: string) => {
142
144
  return "◆ SCOUT · FALLBACK";
143
145
  };
144
146
 
147
+ const scoutSummaryLine = (scout: ScoutToolDetails) =>
148
+ ` ${scout.model}${
149
+ scout.selectedCount === undefined
150
+ ? ""
151
+ : ` · ${scout.selectedCount} kept / ${Math.max(0, (scout.availableCount ?? 0) - scout.selectedCount)} omitted`
152
+ }${scout.latencyMs === undefined ? "" : ` · ${(scout.latencyMs / 1000).toFixed(1)}s`}`;
153
+
154
+ const scoutExpandedLines = (
155
+ scout: ScoutToolDetails,
156
+ expanded: boolean,
157
+ theme: Theme
158
+ ): string[] => {
159
+ const lines: string[] = [];
160
+ if (expanded && scout.selectedLabels?.length) {
161
+ lines.push(
162
+ theme.fg("dim", ` Selected: ${scout.selectedLabels.join("; ")}`)
163
+ );
164
+ }
165
+ if (expanded && scout.synthesis) {
166
+ lines.push(
167
+ theme.fg(
168
+ "dim",
169
+ ` Scout synthesis (untrusted inference): ${scout.synthesis}`
170
+ )
171
+ );
172
+ }
173
+ if (expanded && scout.omittedBeforeScout) {
174
+ lines.push(
175
+ theme.fg(
176
+ "dim",
177
+ ` ${scout.omittedBeforeScout} group(s) omitted before Scout`
178
+ )
179
+ );
180
+ }
181
+ return lines;
182
+ };
183
+
145
184
  export const renderScoutDetails = (
146
185
  box: Box,
147
186
  scout: ScoutToolDetails,
@@ -159,10 +198,7 @@ export const renderScoutDetails = (
159
198
  : "accent",
160
199
  theme.bold(title)
161
200
  ),
162
- theme.fg(
163
- "dim",
164
- ` ${scout.model}${scout.selectedCount === undefined ? "" : ` · ${scout.selectedCount} kept / ${Math.max(0, (scout.availableCount ?? 0) - scout.selectedCount)} omitted`}${scout.latencyMs === undefined ? "" : ` · ${(scout.latencyMs / 1000).toFixed(1)}s`}`
165
- ),
201
+ theme.fg("dim", scoutSummaryLine(scout)),
166
202
  ];
167
203
  if (getAdvisorSettings().showUsageDetails) {
168
204
  const usage = formatAdvisorUsage(scout.usage);
@@ -178,28 +214,7 @@ export const renderScoutDetails = (
178
214
  if (thinking.trim()) {
179
215
  box.addChild(renderThinkingMarkdown(thinking, theme));
180
216
  }
181
- const expandedLines: string[] = [];
182
- if (expanded && scout.selectedLabels?.length) {
183
- expandedLines.push(
184
- theme.fg("dim", ` Selected: ${scout.selectedLabels.join("; ")}`)
185
- );
186
- }
187
- if (expanded && scout.synthesis) {
188
- expandedLines.push(
189
- theme.fg(
190
- "dim",
191
- ` Scout synthesis (untrusted inference): ${scout.synthesis}`
192
- )
193
- );
194
- }
195
- if (expanded && scout.omittedBeforeScout) {
196
- expandedLines.push(
197
- theme.fg(
198
- "dim",
199
- ` ${scout.omittedBeforeScout} group(s) omitted before Scout`
200
- )
201
- );
202
- }
217
+ const expandedLines = scoutExpandedLines(scout, expanded, theme);
203
218
  if (expandedLines.length > 0) {
204
219
  box.addChild(new Text(expandedLines.join("\n"), 0, 0));
205
220
  }
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
2
3
  import type { appendOutcome } from "../outcomes.ts";
3
4
  import type { ScoutOutcome } from "../scout.ts";
4
5
  import type {
package/src/tools.ts CHANGED
@@ -1,4 +1,3 @@
1
- // biome-ignore lint/performance/noBarrelFile: this facade intentionally preserves the public tools module contract.
2
1
  export {
3
2
  consultAdvisor,
4
3
  curateAdvisorConversation,
@@ -1,21 +1,18 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import {
3
- type Component,
4
- type Focusable,
5
- Key,
6
- matchesKey,
7
- truncateToWidth,
8
- } from "@earendil-works/pi-tui";
2
+ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { Component, Focusable } from "@earendil-works/pi-tui";
9
4
  import { noul } from "@typesafe-ai/sdk";
5
+
10
6
  import { jevClientFromCredentials } from "../jev/client.ts";
11
7
  import {
12
8
  clearKeyTypeSafeKey,
13
9
  consumePlaintextKeyWarning,
14
- type JevKeyStoreResult,
15
10
  removeTypeSafeKeyFromAdvisorJson,
16
11
  writeKeyTypeSafeKey,
17
12
  } from "../jev/key-store.ts";
18
- import { type JevCredentials, resolveJevTransport } from "../jev/transport.ts";
13
+ import type { JevKeyStoreResult } from "../jev/key-store.ts";
14
+ import { resolveJevTransport } from "../jev/transport.ts";
15
+ import type { JevCredentials } from "../jev/transport.ts";
19
16
  import { MaskedInput } from "./masked-input.ts";
20
17
  import type { RenderRequester } from "./types.ts";
21
18
 
@@ -48,19 +45,40 @@ type SetupAction =
48
45
  | "enter-key"
49
46
  | "done";
50
47
 
48
+ const ACTION_LABELS: Record<SetupAction, string> = {
49
+ disable: "Disable",
50
+ "disable-clear": "Disable and clear stored key",
51
+ done: "Done",
52
+ "enter-key": "Enter a TypeSafe API key",
53
+ "verify-again": "Verify again",
54
+ "verify-enable": "Verify and enable",
55
+ };
56
+
57
+ const fireAndForget = async (action: Promise<unknown>): Promise<void> => {
58
+ try {
59
+ await action;
60
+ } catch {
61
+ // Background setup actions surface their own notices.
62
+ }
63
+ };
64
+
51
65
  const transportLabel = (credentials: JevCredentials): string => {
52
66
  if (credentials.transport === "openrouter") {
53
67
  return "OpenRouter (reusing pi login)";
54
68
  }
55
69
  switch (credentials.source) {
56
- case "advisor-json":
70
+ case "advisor-json": {
57
71
  return "TypeSafe (key: advisor.json — plaintext, not recommended)";
58
- case "bun-secrets":
72
+ }
73
+ case "bun-secrets": {
59
74
  return "TypeSafe (key: Bun.secrets)";
60
- case "file":
75
+ }
76
+ case "file": {
61
77
  return "TypeSafe (key: stored file, mode 0600)";
62
- default:
78
+ }
79
+ default: {
63
80
  return "TypeSafe (key: TYPESAFE_API_KEY)";
81
+ }
64
82
  }
65
83
  };
66
84
 
@@ -107,7 +125,7 @@ export class JevSetupSubmenu implements Component, Focusable {
107
125
  onSubmit: (value) => this.submitEnteredKey(value),
108
126
  placeholder: "Paste a TypeSafe API key",
109
127
  });
110
- this.refresh().catch(() => undefined);
128
+ void fireAndForget(this.refresh());
111
129
  }
112
130
 
113
131
  get focused(): boolean {
@@ -135,14 +153,14 @@ export class JevSetupSubmenu implements Component, Focusable {
135
153
  const actions = this.actions();
136
154
  if (
137
155
  matchesKey(keyData, Key.down) ||
138
- keyData === "\u001b[B" ||
139
- keyData === "\u001bOB"
156
+ keyData === "\u001B[B" ||
157
+ keyData === "\u001BOB"
140
158
  ) {
141
159
  this.selectedIndex = (this.selectedIndex + 1) % actions.length;
142
160
  } else if (
143
161
  matchesKey(keyData, Key.up) ||
144
- keyData === "\u001b[A" ||
145
- keyData === "\u001bOA"
162
+ keyData === "\u001B[A" ||
163
+ keyData === "\u001BOA"
146
164
  ) {
147
165
  this.selectedIndex =
148
166
  (this.selectedIndex - 1 + actions.length) % actions.length;
@@ -162,28 +180,29 @@ export class JevSetupSubmenu implements Component, Focusable {
162
180
  "",
163
181
  ];
164
182
  if (this.credentials) {
165
- lines.push(` Transport: ${transportLabel(this.credentials)}`);
166
- lines.push(` Filter: ${enabled ? "On" : "Off"}`);
183
+ lines.push(
184
+ ` Transport: ${transportLabel(this.credentials)}`,
185
+ ` Filter: ${enabled ? "On" : "Off"}`
186
+ );
167
187
  } else if (this.mode === "verifying") {
168
188
  lines.push(" Checking available Jev credentials…");
169
189
  } else {
170
190
  lines.push(
171
- " No Jev credentials found. Enter a TypeSafe API key below, or add"
191
+ " No Jev credentials found. Enter a TypeSafe API key below, or add",
192
+ " an OpenRouter login in pi; it is reused automatically."
172
193
  );
173
- lines.push(" an OpenRouter login in pi; it is reused automatically.");
174
194
  }
175
195
  if (this.notice) {
176
- lines.push("");
177
- lines.push(theme.fg("warning", ` ${this.notice}`));
196
+ lines.push("", theme.fg("warning", ` ${this.notice}`));
178
197
  }
179
198
  lines.push("");
180
199
  if (this.mode === "verifying") {
181
200
  lines.push(" Verifying with a live Jev call…");
182
201
  } else if (this.mode === "key-entry") {
183
202
  lines.push(
184
- ` ${this.maskedInput.render(Math.max(10, width - 4))[0] ?? ""}`
203
+ ` ${this.maskedInput.render(Math.max(10, width - 4))[0] ?? ""}`,
204
+ theme.fg("dim", " Enter: verify · Esc: cancel")
185
205
  );
186
- lines.push(theme.fg("dim", " Enter: verify · Esc: cancel"));
187
206
  } else {
188
207
  for (const [index, label] of this.labels().entries()) {
189
208
  const prefix = index === this.selectedIndex ? "→ " : " ";
@@ -210,20 +229,8 @@ export class JevSetupSubmenu implements Component, Focusable {
210
229
  return actions;
211
230
  }
212
231
 
213
- private actionLabels(): Record<SetupAction, string> {
214
- return {
215
- disable: "Disable",
216
- "disable-clear": "Disable and clear stored key",
217
- done: "Done",
218
- "enter-key": "Enter a TypeSafe API key",
219
- "verify-again": "Verify again",
220
- "verify-enable": "Verify and enable",
221
- };
222
- }
223
-
224
232
  private labels(): string[] {
225
- const labels = this.actionLabels();
226
- return this.actions().map((action) => labels[action]);
233
+ return this.actions().map((action) => ACTION_LABELS[action]);
227
234
  }
228
235
 
229
236
  private async refresh(): Promise<void> {
@@ -242,26 +249,25 @@ export class JevSetupSubmenu implements Component, Focusable {
242
249
  }
243
250
 
244
251
  private activate(action: SetupAction): void {
245
- switch (action) {
246
- case "done":
247
- this.options.done();
248
- return;
249
- case "enter-key":
250
- this.mode = "key-entry";
251
- return;
252
- case "disable":
253
- this.notice = undefined;
254
- this.options.done("Off");
255
- return;
256
- case "disable-clear":
257
- this.disableAndClear().catch(() => undefined);
258
- return;
259
- case "verify-again":
260
- case "verify-enable":
261
- this.verifyAndEnable().catch(() => undefined);
262
- return;
263
- default:
264
- return;
252
+ if (action === "done") {
253
+ this.options.done();
254
+ return;
255
+ }
256
+ if (action === "enter-key") {
257
+ this.mode = "key-entry";
258
+ return;
259
+ }
260
+ if (action === "disable") {
261
+ this.notice = undefined;
262
+ this.options.done("Off");
263
+ return;
264
+ }
265
+ if (action === "disable-clear") {
266
+ void fireAndForget(this.disableAndClear());
267
+ return;
268
+ }
269
+ if (action === "verify-again" || action === "verify-enable") {
270
+ void fireAndForget(this.verifyAndEnable());
265
271
  }
266
272
  }
267
273
 
@@ -3,6 +3,7 @@ import {
3
3
  visibleWidth,
4
4
  wrapTextWithAnsi,
5
5
  } from "@earendil-works/pi-tui";
6
+
6
7
  import type { GitContextLevel } from "../git.ts";
7
8
  import type { ManualAdvisorDialogView } from "./types.ts";
8
9
 
@@ -1,29 +1,34 @@
1
- import {
2
- type Component,
3
- Editor,
4
- type EditorTheme,
5
- type Focusable,
6
- Key,
7
- type Keybindings,
8
- type KeyId,
9
- matchesKey,
1
+ import { Editor, Key, matchesKey } from "@earendil-works/pi-tui";
2
+ import type {
3
+ Component,
4
+ EditorTheme,
5
+ Focusable,
6
+ Keybindings,
7
+ KeyId,
10
8
  } from "@earendil-works/pi-tui";
11
- import {
12
- clampGitContextLevel,
13
- GIT_CONTEXT_LEVELS,
14
- type GitContextLevel,
15
- } from "../git.ts";
9
+
10
+ import { clampGitContextLevel, GIT_CONTEXT_LEVELS } from "../git.ts";
11
+ import type { GitContextLevel } from "../git.ts";
16
12
  import { renderManualAdvisorDialog } from "./manual-dialog-render.ts";
17
13
  import type {
18
14
  ManualAdvisorDialogOptions,
19
15
  ManualAdvisorFocus,
16
+ ManualAdvisorRequest,
20
17
  } from "./types.ts";
21
18
 
22
- // Keep the keybinding components separate from Socket's URL-string heuristic.
19
+ // SAFETY: the joined literal is exactly the registered "tui.input.tab" keybinding path.
23
20
  const TUI_INPUT_TAB: keyof Keybindings = ["tui", "input", "tab"].join(
24
21
  "."
25
22
  ) as keyof Keybindings;
26
23
 
24
+ interface ManualDialogState {
25
+ completed: boolean;
26
+ focused: boolean;
27
+ }
28
+
29
+ const isShiftTab = (keyData: string): boolean =>
30
+ matchesKey(keyData, Key.shift("tab"));
31
+
27
32
  /** The small, non-persistent /advisor-manual form; callers own budget and consultation decisions. */
28
33
  export class ManualAdvisorDialog implements Component, Focusable {
29
34
  private readonly options: ManualAdvisorDialogOptions;
@@ -32,7 +37,7 @@ export class ManualAdvisorDialog implements Component, Focusable {
32
37
  private gitIndex: number;
33
38
  private actionIndex = 0;
34
39
  private focusTarget: ManualAdvisorFocus = "editor";
35
- private readonly state: { completed: boolean; focused: boolean } = {
40
+ private readonly state: ManualDialogState = {
36
41
  completed: false,
37
42
  focused: false,
38
43
  };
@@ -54,7 +59,7 @@ export class ManualAdvisorDialog implements Component, Focusable {
54
59
  // while the configured level remains the hard ceiling.
55
60
  this.gitLevels = GIT_CONTEXT_LEVELS.filter(
56
61
  (level) => clampGitContextLevel(level, options.gitContext) === level
57
- ).reverse();
62
+ ).toReversed();
58
63
  this.gitIndex = Math.max(0, this.gitLevels.indexOf(options.gitContext));
59
64
 
60
65
  const editorTheme: EditorTheme = {
@@ -90,7 +95,7 @@ export class ManualAdvisorDialog implements Component, Focusable {
90
95
  this.cancel();
91
96
  return;
92
97
  }
93
- if (this.isShiftTab(keyData)) {
98
+ if (isShiftTab(keyData)) {
94
99
  this.changeFocus(-1);
95
100
  return;
96
101
  }
@@ -98,24 +103,12 @@ export class ManualAdvisorDialog implements Component, Focusable {
98
103
  this.changeFocus(1);
99
104
  return;
100
105
  }
101
-
102
- switch (this.focusTarget) {
103
- case "editor":
104
- // Editor owns Enter/Shift+Enter and all cursor/editing semantics. Its
105
- // onSubmit callback above is the sole editor submission path. At the
106
- // edge of the message, a directional key also moves to the adjacent
107
- // form control so the Git selector is reachable without Tab.
108
- this.handleEditorInput(keyData);
109
- return;
110
- case "git":
111
- this.handleGitInput(keyData);
112
- return;
113
- case "actions":
114
- this.handleActionInput(keyData);
115
- return;
116
- default:
117
- return;
118
- }
106
+ const handlers: Record<ManualAdvisorFocus, (keyData: string) => void> = {
107
+ actions: (key) => this.handleActionInput(key),
108
+ editor: (key) => this.handleEditorInput(key),
109
+ git: (key) => this.handleGitInput(key),
110
+ };
111
+ handlers[this.focusTarget](keyData);
119
112
  }
120
113
 
121
114
  render(width: number): string[] {
@@ -137,6 +130,10 @@ export class ManualAdvisorDialog implements Component, Focusable {
137
130
  }
138
131
 
139
132
  private handleEditorInput(keyData: string): void {
133
+ // Editor owns Enter/Shift+Enter and all cursor/editing semantics. Its
134
+ // onSubmit callback above is the sole editor submission path. At the
135
+ // edge of the message, a directional key also moves to the adjacent
136
+ // form control so the Git selector is reachable without Tab.
140
137
  let direction: -1 | 1 | undefined;
141
138
  if (this.matches(keyData, "tui.editor.cursorDown", Key.down)) {
142
139
  direction = 1;
@@ -257,10 +254,6 @@ export class ManualAdvisorDialog implements Component, Focusable {
257
254
  );
258
255
  }
259
256
 
260
- private isShiftTab(keyData: string): boolean {
261
- return matchesKey(keyData, Key.shift("tab"));
262
- }
263
-
264
257
  private isCancel(keyData: string): boolean {
265
258
  return (
266
259
  matchesKey(keyData, Key.escape) ||
@@ -284,10 +277,13 @@ export class ManualAdvisorDialog implements Component, Focusable {
284
277
  return;
285
278
  }
286
279
  this.state.completed = true;
287
- this.options.onSubmit({
280
+ const request: ManualAdvisorRequest = {
288
281
  gitContext: this.gitLevels[this.gitIndex] ?? "off",
289
- ...(message ? { message } : {}),
290
- });
282
+ };
283
+ if (message) {
284
+ request.message = message;
285
+ }
286
+ this.options.onSubmit(request);
291
287
  }
292
288
 
293
289
  private cancel(): void {