@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.2

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 (165) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +10 -10
  4. package/scripts/build-binary.ts +5 -0
  5. package/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commands/commit.ts +10 -0
  14. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  15. package/src/commit/analysis/conventional.ts +8 -66
  16. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  17. package/src/commit/pipeline.ts +2 -2
  18. package/src/commit/shared-llm.ts +89 -0
  19. package/src/config/config-file.ts +210 -0
  20. package/src/config/model-equivalence.ts +8 -11
  21. package/src/config/model-registry.ts +44 -3
  22. package/src/config/model-resolver.ts +1 -4
  23. package/src/config/settings-schema.ts +82 -1
  24. package/src/config/settings.ts +1 -1
  25. package/src/config.ts +3 -219
  26. package/src/discovery/claude-plugins.ts +19 -7
  27. package/src/edit/renderer.ts +7 -1
  28. package/src/eval/js/executor.ts +3 -0
  29. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  30. package/src/eval/py/executor.ts +5 -0
  31. package/src/eval/py/runner.py +42 -11
  32. package/src/eval/py/runtime.ts +1 -0
  33. package/src/exa/factory.ts +2 -2
  34. package/src/exa/mcp-client.ts +74 -1
  35. package/src/exec/bash-executor.ts +5 -1
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +0 -11
  38. package/src/extensibility/extensions/get-commands-handler.ts +77 -0
  39. package/src/extensibility/extensions/runner.ts +1 -1
  40. package/src/extensibility/extensions/types.ts +89 -223
  41. package/src/extensibility/hooks/types.ts +89 -314
  42. package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
  43. package/src/extensibility/shared-events.ts +343 -0
  44. package/src/extensibility/skills.ts +9 -0
  45. package/src/goals/index.ts +3 -0
  46. package/src/goals/runtime.ts +500 -0
  47. package/src/goals/state.ts +37 -0
  48. package/src/goals/tools/goal-tool.ts +237 -0
  49. package/src/hashline/anchors.ts +2 -2
  50. package/src/hashline/input.ts +2 -1
  51. package/src/hashline/parser.ts +27 -3
  52. package/src/hindsight/mental-models.ts +1 -1
  53. package/src/internal-urls/agent-protocol.ts +1 -20
  54. package/src/internal-urls/artifact-protocol.ts +1 -19
  55. package/src/internal-urls/docs-index.generated.ts +11 -12
  56. package/src/internal-urls/registry-helpers.ts +25 -0
  57. package/src/internal-urls/router.ts +8 -0
  58. package/src/internal-urls/types.ts +21 -0
  59. package/src/lsp/config.ts +15 -6
  60. package/src/lsp/defaults.json +6 -2
  61. package/src/main.ts +11 -2
  62. package/src/mcp/oauth-flow.ts +20 -0
  63. package/src/modes/acp/acp-agent.ts +327 -95
  64. package/src/modes/components/assistant-message.ts +14 -8
  65. package/src/modes/components/bash-execution.ts +24 -63
  66. package/src/modes/components/custom-message.ts +14 -40
  67. package/src/modes/components/eval-execution.ts +27 -57
  68. package/src/modes/components/execution-shared.ts +102 -0
  69. package/src/modes/components/hook-message.ts +17 -49
  70. package/src/modes/components/mcp-add-wizard.ts +26 -5
  71. package/src/modes/components/message-frame.ts +88 -0
  72. package/src/modes/components/model-selector.ts +1 -1
  73. package/src/modes/components/session-observer-overlay.ts +6 -2
  74. package/src/modes/components/session-selector.ts +1 -1
  75. package/src/modes/components/status-line/segments.ts +93 -8
  76. package/src/modes/components/status-line/types.ts +4 -0
  77. package/src/modes/components/status-line.ts +28 -10
  78. package/src/modes/components/tool-execution.ts +7 -8
  79. package/src/modes/controllers/command-controller-shared.ts +108 -0
  80. package/src/modes/controllers/command-controller.ts +13 -4
  81. package/src/modes/controllers/event-controller.ts +36 -7
  82. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  83. package/src/modes/controllers/input-controller.ts +13 -0
  84. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  85. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  86. package/src/modes/interactive-mode.ts +624 -52
  87. package/src/modes/print-mode.ts +16 -86
  88. package/src/modes/rpc/host-uris.ts +235 -0
  89. package/src/modes/rpc/rpc-mode.ts +41 -88
  90. package/src/modes/rpc/rpc-types.ts +57 -0
  91. package/src/modes/runtime-init.ts +116 -0
  92. package/src/modes/theme/defaults/dark-poimandres.json +3 -0
  93. package/src/modes/theme/defaults/light-poimandres.json +3 -0
  94. package/src/modes/theme/theme.ts +24 -6
  95. package/src/modes/types.ts +14 -3
  96. package/src/modes/utils/context-usage.ts +13 -13
  97. package/src/modes/utils/ui-helpers.ts +10 -3
  98. package/src/plan-mode/approved-plan.ts +35 -1
  99. package/src/prompts/goals/goal-budget-limit.md +16 -0
  100. package/src/prompts/goals/goal-continuation.md +28 -0
  101. package/src/prompts/goals/goal-mode-active.md +23 -0
  102. package/src/prompts/system/plan-mode-active.md +5 -5
  103. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  104. package/src/prompts/tools/bash.md +6 -0
  105. package/src/prompts/tools/github.md +4 -4
  106. package/src/prompts/tools/goal.md +13 -0
  107. package/src/prompts/tools/hashline.md +101 -117
  108. package/src/prompts/tools/read.md +55 -36
  109. package/src/prompts/tools/resolve.md +6 -5
  110. package/src/sdk.ts +12 -5
  111. package/src/session/agent-session.ts +428 -106
  112. package/src/session/blob-store.ts +36 -3
  113. package/src/session/messages.ts +67 -2
  114. package/src/session/session-manager.ts +131 -12
  115. package/src/session/session-storage.ts +33 -15
  116. package/src/session/streaming-output.ts +309 -13
  117. package/src/slash-commands/builtin-registry.ts +18 -0
  118. package/src/ssh/ssh-executor.ts +5 -0
  119. package/src/system-prompt.ts +4 -2
  120. package/src/task/discovery.ts +5 -2
  121. package/src/task/executor.ts +19 -8
  122. package/src/task/index.ts +3 -0
  123. package/src/task/render.ts +21 -15
  124. package/src/task/types.ts +4 -0
  125. package/src/tools/ast-edit.ts +21 -120
  126. package/src/tools/ast-grep.ts +21 -119
  127. package/src/tools/bash-command-fixup.ts +47 -0
  128. package/src/tools/bash-interactive.ts +9 -1
  129. package/src/tools/bash.ts +66 -19
  130. package/src/tools/browser/attach.ts +3 -3
  131. package/src/tools/browser/launch.ts +81 -18
  132. package/src/tools/browser/registry.ts +1 -5
  133. package/src/tools/browser/render.ts +2 -2
  134. package/src/tools/browser/tab-supervisor.ts +51 -14
  135. package/src/tools/conflict-detect.ts +15 -4
  136. package/src/tools/eval.ts +12 -2
  137. package/src/tools/find.ts +20 -38
  138. package/src/tools/gh.ts +44 -10
  139. package/src/tools/index.ts +22 -11
  140. package/src/tools/inspect-image.ts +3 -10
  141. package/src/tools/job.ts +16 -7
  142. package/src/tools/output-meta.ts +202 -37
  143. package/src/tools/path-utils.ts +125 -2
  144. package/src/tools/read.ts +548 -237
  145. package/src/tools/render-utils.ts +92 -0
  146. package/src/tools/renderers.ts +2 -0
  147. package/src/tools/resolve.ts +72 -44
  148. package/src/tools/search.ts +120 -186
  149. package/src/tools/ssh.ts +3 -2
  150. package/src/tools/write.ts +64 -9
  151. package/src/utils/file-mentions.ts +1 -1
  152. package/src/utils/image-loading.ts +7 -3
  153. package/src/utils/image-resize.ts +32 -43
  154. package/src/vim/parser.ts +0 -17
  155. package/src/vim/render.ts +1 -1
  156. package/src/vim/types.ts +1 -1
  157. package/src/web/search/providers/anthropic.ts +5 -0
  158. package/src/web/search/providers/exa.ts +3 -0
  159. package/src/web/search/providers/gemini.ts +40 -95
  160. package/src/web/search/providers/jina.ts +5 -2
  161. package/src/web/search/providers/zai.ts +5 -2
  162. package/src/prompts/tools/exit-plan-mode.md +0 -6
  163. package/src/tools/exit-plan-mode.ts +0 -97
  164. package/src/utils/fuzzy.ts +0 -108
  165. package/src/utils/image-convert.ts +0 -27
@@ -16,6 +16,7 @@ import type {
16
16
  SendUserMessageHandler,
17
17
  TerminalInputHandler,
18
18
  } from "../../extensibility/extensions";
19
+ import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
19
20
  import { HookEditorComponent } from "../../modes/components/hook-editor";
20
21
  import { HookInputComponent } from "../../modes/components/hook-input";
21
22
  import { HookSelectorComponent } from "../../modes/components/hook-selector";
@@ -109,7 +110,7 @@ export class ExtensionUiController {
109
110
  },
110
111
  getThinkingLevel: () => this.ctx.session.thinkingLevel,
111
112
  setThinkingLevel: level => this.ctx.session.setThinkingLevel(level),
112
- getCommands: () => [],
113
+ getCommands: () => getSessionSlashCommands(this.ctx.session),
113
114
  getSessionName: () => this.ctx.sessionManager.getSessionName(),
114
115
  setSessionName: name => this.#updateSessionName(name),
115
116
  };
@@ -349,7 +350,7 @@ export class ExtensionUiController {
349
350
  },
350
351
  getThinkingLevel: () => this.ctx.session.thinkingLevel,
351
352
  setThinkingLevel: (level, persist) => this.ctx.session.setThinkingLevel(level, persist),
352
- getCommands: () => [],
353
+ getCommands: () => getSessionSlashCommands(this.ctx.session),
353
354
  getSessionName: () => this.ctx.sessionManager.getSessionName(),
354
355
  setSessionName: name => this.#updateSessionName(name),
355
356
  };
@@ -443,6 +443,15 @@ export class InputController {
443
443
  args: args || undefined,
444
444
  lineCount: body ? body.split("\n").length : 0,
445
445
  };
446
+ // When the agent is streaming, register the compact slash-form text as
447
+ // the pending-display twin BEFORE dispatching the CustomMessage. The
448
+ // returned tag is embedded in details so AgentSession.#handleAgentEvent
449
+ // can remove the matching display entry when the agent consumes this
450
+ // message (mirrors the user-message dequeue path).
451
+ if (this.ctx.session.isStreaming) {
452
+ const tag = this.ctx.session.enqueueCustomMessageDisplay(text, streamingBehavior);
453
+ details.__pendingDisplayTag = tag;
454
+ }
446
455
  await this.ctx.session.promptCustomMessage(
447
456
  {
448
457
  customType: SKILL_PROMPT_MESSAGE_TYPE,
@@ -453,6 +462,10 @@ export class InputController {
453
462
  },
454
463
  { streamingBehavior },
455
464
  );
465
+ if (this.ctx.session.isStreaming) {
466
+ this.ctx.updatePendingMessagesDisplay();
467
+ this.ctx.ui.requestRender();
468
+ }
456
469
  } catch (err) {
457
470
  this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
458
471
  }
@@ -37,11 +37,11 @@ import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../
37
37
  import type { OAuthCredential } from "../../session/auth-storage";
38
38
  import { shortenPath } from "../../tools/render-utils";
39
39
  import { openPath } from "../../utils/open";
40
- import { DynamicBorder } from "../components/dynamic-border";
41
40
  import { MCPAddWizard } from "../components/mcp-add-wizard";
42
41
  import { parseCommandArgs } from "../shared";
43
42
  import { theme } from "../theme/theme";
44
43
  import type { InteractiveModeContext } from "../types";
44
+ import { groupBySource, parseRemoveArgs, readScopeFlag, showCommandMessage } from "./command-controller-shared";
45
45
 
46
46
  function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
47
47
  const { promise: timeoutPromise, reject } = Promise.withResolvers<T>();
@@ -49,6 +49,22 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string)
49
49
  return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
50
50
  }
51
51
 
52
+ /**
53
+ * Outcome of {@link MCPCommandController}'s OAuth handler.
54
+ *
55
+ * `clientId`/`clientSecret` are populated when the OAuth provider required (or
56
+ * accepted) dynamic client registration; callers MUST persist them alongside
57
+ * `credentialId` so subsequent token refreshes and reauthorizations can reuse
58
+ * the same registered client. Both are also set when the caller pre-supplied a
59
+ * client id via the wizard or `oauth.clientId` in `mcp.json`, in which case the
60
+ * write-back is a no-op.
61
+ */
62
+ interface OAuthFlowResult {
63
+ credentialId: string;
64
+ clientId?: string;
65
+ clientSecret?: string;
66
+ }
67
+
52
68
  type MCPAddScope = "user" | "project";
53
69
  type MCPAddTransport = "http" | "sse";
54
70
 
@@ -207,11 +223,11 @@ export class MCPCommandController {
207
223
  break;
208
224
  }
209
225
  if (argToken === "--scope") {
210
- const value = tokens[i + 1];
211
- if (!value || (value !== "project" && value !== "user")) {
212
- return { scope, error: "Invalid --scope value. Use project or user." };
226
+ const r = readScopeFlag(tokens[i + 1]);
227
+ if (!r.ok) {
228
+ return { scope, error: r.error };
213
229
  }
214
- scope = value;
230
+ scope = r.scope;
215
231
  i += 2;
216
232
  continue;
217
233
  }
@@ -406,7 +422,7 @@ export class MCPCommandController {
406
422
 
407
423
  try {
408
424
  const oauthClientSecret = finalConfig.oauth?.clientSecret ?? "";
409
- const credentialId = await this.#handleOAuthFlow(
425
+ const oauthResult = await this.#handleOAuthFlow(
410
426
  oauth.authorizationUrl,
411
427
  oauth.tokenUrl,
412
428
  oauth.clientId ?? finalConfig.oauth?.clientId ?? "",
@@ -416,14 +432,21 @@ export class MCPCommandController {
416
432
  finalConfig.oauth?.callbackPath,
417
433
  finalConfig.oauth?.redirectUri,
418
434
  );
435
+ const persistedClientId = oauthResult.clientId ?? oauth.clientId ?? finalConfig.oauth?.clientId;
436
+ const persistedClientSecret = oauthResult.clientSecret ?? finalConfig.oauth?.clientSecret;
419
437
  finalConfig = {
420
438
  ...finalConfig,
421
439
  auth: {
422
440
  type: "oauth",
423
- credentialId,
441
+ credentialId: oauthResult.credentialId,
424
442
  tokenUrl: oauth.tokenUrl,
425
- clientId: oauth.clientId ?? finalConfig.oauth?.clientId,
426
- clientSecret: finalConfig.oauth?.clientSecret,
443
+ clientId: persistedClientId,
444
+ clientSecret: persistedClientSecret,
445
+ },
446
+ oauth: {
447
+ ...finalConfig.oauth,
448
+ clientId: persistedClientId ?? finalConfig.oauth?.clientId,
449
+ clientSecret: persistedClientSecret ?? finalConfig.oauth?.clientSecret,
427
450
  },
428
451
  };
429
452
  } catch (oauthError) {
@@ -488,7 +511,7 @@ export class MCPCommandController {
488
511
  callbackPort?: number,
489
512
  callbackPath?: string,
490
513
  redirectUri?: string,
491
- ): Promise<string> {
514
+ ): Promise<OAuthFlowResult> {
492
515
  const authStorage = this.ctx.session.modelRegistry.authStorage;
493
516
  let parsedAuthUrl: URL;
494
517
 
@@ -600,7 +623,11 @@ export class MCPCommandController {
600
623
  // Store under a synthetic provider name
601
624
  await authStorage.set(credentialId, oauthCredential);
602
625
 
603
- return credentialId;
626
+ return {
627
+ credentialId,
628
+ clientId: flow.resolvedClientId,
629
+ clientSecret: flow.registeredClientSecret,
630
+ };
604
631
  } catch (error) {
605
632
  const errorMsg = error instanceof Error ? error.message : String(error);
606
633
 
@@ -984,23 +1011,7 @@ export class MCPCommandController {
984
1011
 
985
1012
  // Show discovered servers (from .claude.json, .cursor/mcp.json, .vscode/mcp.json, etc.)
986
1013
  if (discoveredServers.length > 0) {
987
- // Group by source display name + path
988
- const bySource = new Map<string, typeof discoveredServers>();
989
- for (const entry of discoveredServers) {
990
- const key = `${entry.source.providerName}|${entry.source.path}`;
991
- let group = bySource.get(key);
992
- if (!group) {
993
- group = [];
994
- bySource.set(key, group);
995
- }
996
- group.push(entry);
997
- }
998
-
999
- for (const [key, entries] of bySource) {
1000
- const sepIdx = key.indexOf("|");
1001
- const providerName = key.slice(0, sepIdx);
1002
- const sourcePath = key.slice(sepIdx + 1);
1003
- const shortPath = shortenPath(sourcePath);
1014
+ for (const { providerName, shortPath, items: entries } of groupBySource(discoveredServers, e => e.source)) {
1004
1015
  lines.push(theme.fg("accent", providerName) + theme.fg("muted", ` (${shortPath}):`));
1005
1016
  for (const { name } of entries) {
1006
1017
  const state = this.ctx.mcpManager!.getConnectionStatus(name);
@@ -1037,32 +1048,12 @@ export class MCPCommandController {
1037
1048
  async #handleRemove(text: string): Promise<void> {
1038
1049
  const match = text.match(/^\/mcp\s+(?:remove|rm)\b\s*(.*)$/i);
1039
1050
  const rest = match?.[1]?.trim() ?? "";
1040
- const tokens = parseCommandArgs(rest);
1041
-
1042
- let name: string | undefined;
1043
- let scope: "project" | "user" = "project";
1044
- let i = 0;
1045
-
1046
- if (tokens.length > 0 && !tokens[0].startsWith("-")) {
1047
- name = tokens[0];
1048
- i = 1;
1049
- }
1050
-
1051
- while (i < tokens.length) {
1052
- const token = tokens[i];
1053
- if (token === "--scope") {
1054
- const value = tokens[i + 1];
1055
- if (!value || (value !== "project" && value !== "user")) {
1056
- this.ctx.showError("Invalid --scope value. Use project or user.");
1057
- return;
1058
- }
1059
- scope = value;
1060
- i += 2;
1061
- continue;
1062
- }
1063
- this.ctx.showError(`Unknown option: ${token}`);
1051
+ const parsed = parseRemoveArgs(rest);
1052
+ if (!parsed.ok) {
1053
+ this.ctx.showError(parsed.error);
1064
1054
  return;
1065
1055
  }
1056
+ const { name, scope } = parsed.value;
1066
1057
 
1067
1058
  if (!name) {
1068
1059
  this.ctx.showError("Server name required. Usage: /mcp remove <name> [--scope project|user]");
@@ -1348,7 +1339,7 @@ export class MCPCommandController {
1348
1339
 
1349
1340
  this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n"));
1350
1341
 
1351
- const credentialId = await this.#handleOAuthFlow(
1342
+ const oauthResult = await this.#handleOAuthFlow(
1352
1343
  oauth.authorizationUrl,
1353
1344
  oauth.tokenUrl,
1354
1345
  oauth.clientId ?? found.config.oauth?.clientId ?? "",
@@ -1359,14 +1350,22 @@ export class MCPCommandController {
1359
1350
  found.config.oauth?.redirectUri,
1360
1351
  );
1361
1352
 
1353
+ const persistedClientId = oauthResult.clientId ?? oauth.clientId ?? found.config.oauth?.clientId;
1354
+ const persistedClientSecret = oauthResult.clientSecret ?? (oauthClientSecret || undefined);
1355
+
1362
1356
  const updated: MCPServerConfig = {
1363
1357
  ...baseConfig,
1364
1358
  auth: {
1365
1359
  type: "oauth",
1366
- credentialId,
1360
+ credentialId: oauthResult.credentialId,
1367
1361
  tokenUrl: oauth.tokenUrl,
1368
- clientId: oauth.clientId ?? found.config.oauth?.clientId,
1369
- clientSecret: oauthClientSecret || undefined,
1362
+ clientId: persistedClientId,
1363
+ clientSecret: persistedClientSecret,
1364
+ },
1365
+ oauth: {
1366
+ ...found.config.oauth,
1367
+ clientId: persistedClientId ?? found.config.oauth?.clientId,
1368
+ clientSecret: persistedClientSecret ?? found.config.oauth?.clientSecret,
1370
1369
  },
1371
1370
  };
1372
1371
  await updateMCPServer(found.filePath, name, updated);
@@ -1929,10 +1928,6 @@ export class MCPCommandController {
1929
1928
  * Show a message in the chat
1930
1929
  */
1931
1930
  #showMessage(text: string): void {
1932
- this.ctx.chatContainer.addChild(new Spacer(1));
1933
- this.ctx.chatContainer.addChild(new DynamicBorder());
1934
- this.ctx.chatContainer.addChild(new Text(text, 1, 1));
1935
- this.ctx.chatContainer.addChild(new DynamicBorder());
1936
- this.ctx.ui.requestRender();
1931
+ showCommandMessage(this.ctx, text);
1937
1932
  }
1938
1933
  }
@@ -3,18 +3,20 @@
3
3
  *
4
4
  * Handles /ssh subcommands for managing SSH host configurations.
5
5
  */
6
- import { Spacer, Text } from "@oh-my-pi/pi-tui";
7
6
  import { getProjectDir, getSSHConfigPath } from "@oh-my-pi/pi-utils";
8
7
  import { type SSHHost, sshCapability } from "../../capability/ssh";
9
8
  import { loadCapability } from "../../discovery";
10
9
  import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../../ssh/config-writer";
11
- import { shortenPath } from "../../tools/render-utils";
12
- import { DynamicBorder } from "../components/dynamic-border";
13
10
  import { parseCommandArgs } from "../shared";
14
11
  import { theme } from "../theme/theme";
15
12
  import type { InteractiveModeContext } from "../types";
16
-
17
- type SSHAddScope = "user" | "project";
13
+ import {
14
+ groupBySource,
15
+ parseRemoveArgs,
16
+ readScopeFlag,
17
+ type ScopeValue,
18
+ showCommandMessage,
19
+ } from "./command-controller-shared";
18
20
 
19
21
  export class SSHCommandController {
20
22
  constructor(private ctx: InteractiveModeContext) {}
@@ -90,7 +92,7 @@ export class SSHCommandController {
90
92
  }
91
93
 
92
94
  let name: string | undefined;
93
- let scope: SSHAddScope = "project";
95
+ let scope: ScopeValue = "project";
94
96
  let host: string | undefined;
95
97
  let username: string | undefined;
96
98
  let port: number | undefined;
@@ -167,12 +169,12 @@ export class SSHCommandController {
167
169
  continue;
168
170
  }
169
171
  if (argToken === "--scope") {
170
- const value = tokens[i + 1];
171
- if (!value || (value !== "project" && value !== "user")) {
172
- this.ctx.showError("Invalid --scope value. Use project or user.");
172
+ const r = readScopeFlag(tokens[i + 1]);
173
+ if (!r.ok) {
174
+ this.ctx.showError(r.error);
173
175
  return;
174
176
  }
175
- scope = value;
177
+ scope = r.scope;
176
178
  i += 2;
177
179
  continue;
178
180
  }
@@ -300,23 +302,7 @@ export class SSHCommandController {
300
302
 
301
303
  // Show discovered hosts (from ssh.json, .ssh.json in project root, etc.)
302
304
  if (discoveredHosts.length > 0) {
303
- // Group by source
304
- const bySource = new Map<string, SSHHost[]>();
305
- for (const host of discoveredHosts) {
306
- const key = `${host._source.providerName}|${host._source.path}`;
307
- let group = bySource.get(key);
308
- if (!group) {
309
- group = [];
310
- bySource.set(key, group);
311
- }
312
- group.push(host);
313
- }
314
-
315
- for (const [key, hosts] of bySource) {
316
- const sepIdx = key.indexOf("|");
317
- const providerName = key.slice(0, sepIdx);
318
- const sourcePath = key.slice(sepIdx + 1);
319
- const shortPath = shortenPath(sourcePath);
305
+ for (const { providerName, shortPath, items: hosts } of groupBySource(discoveredHosts, h => h._source)) {
320
306
  lines.push(
321
307
  theme.fg("accent", "Discovered") +
322
308
  theme.fg("muted", ` (${providerName}: ${shortPath}):`) +
@@ -357,33 +343,12 @@ export class SSHCommandController {
357
343
  async #handleRemove(text: string): Promise<void> {
358
344
  const match = text.match(/^\/ssh\s+(?:remove|rm)\b\s*(.*)$/i);
359
345
  const rest = match?.[1]?.trim() ?? "";
360
- const tokens = parseCommandArgs(rest);
361
-
362
- let name: string | undefined;
363
- let scope: "project" | "user" = "project";
364
- let i = 0;
365
-
366
- if (tokens.length > 0 && !tokens[0].startsWith("-")) {
367
- name = tokens[0];
368
- i = 1;
369
- }
370
-
371
- while (i < tokens.length) {
372
- const token = tokens[i];
373
- if (token === "--scope") {
374
- const value = tokens[i + 1];
375
- if (!value || (value !== "project" && value !== "user")) {
376
- this.ctx.showError("Invalid --scope value. Use project or user.");
377
- return;
378
- }
379
- scope = value;
380
- i += 2;
381
- continue;
382
- }
383
- this.ctx.showError(`Unknown option: ${token}`);
346
+ const parsed = parseRemoveArgs(rest);
347
+ if (!parsed.ok) {
348
+ this.ctx.showError(parsed.error);
384
349
  return;
385
350
  }
386
-
351
+ const { name, scope } = parsed.value;
387
352
  if (!name) {
388
353
  this.ctx.showError("Host name required. Usage: /ssh remove <name> [--scope project|user]");
389
354
  return;
@@ -412,10 +377,6 @@ export class SSHCommandController {
412
377
  * Show a message in the chat
413
378
  */
414
379
  #showMessage(text: string): void {
415
- this.ctx.chatContainer.addChild(new Spacer(1));
416
- this.ctx.chatContainer.addChild(new DynamicBorder());
417
- this.ctx.chatContainer.addChild(new Text(text, 1, 1));
418
- this.ctx.chatContainer.addChild(new DynamicBorder());
419
- this.ctx.ui.requestRender();
380
+ showCommandMessage(this.ctx, text);
420
381
  }
421
382
  }