@gajae-code/coding-agent 0.12.7 → 0.12.8

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 (95) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/types/commands/notify.d.ts +4 -0
  3. package/dist/types/config/file-lock.d.ts +3 -0
  4. package/dist/types/config/model-discovery-manager.d.ts +6 -1
  5. package/dist/types/config/model-registry.d.ts +2 -0
  6. package/dist/types/config/resolve-config-value.d.ts +2 -1
  7. package/dist/types/config/settings-schema.d.ts +17 -1
  8. package/dist/types/main.d.ts +1 -0
  9. package/dist/types/modes/components/hook-selector.d.ts +20 -0
  10. package/dist/types/modes/components/notifications-settings-editor.d.ts +53 -4
  11. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  12. package/dist/types/modes/controllers/selector-controller.d.ts +4 -2
  13. package/dist/types/sdk/broker/broker.d.ts +5 -1
  14. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
  15. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  16. package/dist/types/sdk/bus/config.d.ts +75 -113
  17. package/dist/types/sdk/bus/discord-live-provider.d.ts +8 -2
  18. package/dist/types/sdk/bus/discord-provider.d.ts +19 -0
  19. package/dist/types/sdk/bus/index.d.ts +1 -1
  20. package/dist/types/sdk/bus/notification-orchestration.d.ts +127 -18
  21. package/dist/types/sdk/bus/notification-service.d.ts +31 -4
  22. package/dist/types/sdk/bus/session-control.d.ts +8 -3
  23. package/dist/types/sdk/bus/slack-live-provider.d.ts +9 -2
  24. package/dist/types/sdk/bus/slack-provider.d.ts +22 -0
  25. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  26. package/dist/types/sdk/client/discovery.d.ts +2 -1
  27. package/dist/types/sdk/host/query/handlers.d.ts +2 -0
  28. package/dist/types/sdk/index.d.ts +1 -0
  29. package/dist/types/sdk/providers.d.ts +22 -0
  30. package/dist/types/sdk/session-directory.d.ts +1 -1
  31. package/dist/types/session/internal/managed-session-scope.d.ts +17 -1
  32. package/dist/types/session/internal/managed-session-storage.d.ts +11 -1
  33. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -0
  34. package/dist/types/session/internal/session-open-errors.d.ts +5 -0
  35. package/dist/types/session/session-manager.d.ts +3 -1
  36. package/dist/types/session/session-storage.d.ts +2 -0
  37. package/package.json +10 -8
  38. package/scripts/build-sdk-package-smoke.ts +51 -0
  39. package/scripts/capture-notifications-settings-showcase.ts +1 -1
  40. package/src/cli/notify-cli.ts +204 -94
  41. package/src/commands/notify.ts +19 -8
  42. package/src/config/file-lock.ts +6 -0
  43. package/src/config/model-discovery-manager.ts +50 -8
  44. package/src/config/model-registry.ts +862 -75
  45. package/src/config/resolve-config-value.ts +10 -8
  46. package/src/config/settings-schema.ts +25 -15
  47. package/src/config/settings.ts +15 -15
  48. package/src/gjc-runtime/session-state-sidecar.ts +12 -2
  49. package/src/internal-urls/docs-index.generated.ts +6 -6
  50. package/src/main.ts +43 -7
  51. package/src/modes/DESIGN.md +11 -11
  52. package/src/modes/components/hook-selector.ts +155 -44
  53. package/src/modes/components/model-selector.ts +242 -49
  54. package/src/modes/components/notifications-settings-editor.ts +631 -85
  55. package/src/modes/controllers/extension-ui-controller.ts +114 -15
  56. package/src/modes/controllers/selector-controller.ts +464 -50
  57. package/src/sdk/broker/broker.ts +54 -10
  58. package/src/sdk/broker/lifecycle-ledger.ts +34 -1
  59. package/src/sdk/broker/lifecycle.ts +47 -50
  60. package/src/sdk/bus/chat-daemon-cli.ts +12 -5
  61. package/src/sdk/bus/chat-daemon-control.ts +35 -6
  62. package/src/sdk/bus/chat-daemon-runtime.ts +16 -2
  63. package/src/sdk/bus/config.ts +534 -286
  64. package/src/sdk/bus/discord-live-provider.ts +57 -2
  65. package/src/sdk/bus/discord-provider.ts +21 -0
  66. package/src/sdk/bus/index.ts +97 -35
  67. package/src/sdk/bus/notification-orchestration.ts +371 -68
  68. package/src/sdk/bus/notification-service.ts +431 -130
  69. package/src/sdk/bus/session-control.ts +119 -44
  70. package/src/sdk/bus/slack-live-provider.ts +78 -2
  71. package/src/sdk/bus/slack-provider.ts +24 -0
  72. package/src/sdk/bus/telegram-daemon-cli.ts +9 -2
  73. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  74. package/src/sdk/bus/telegram-daemon-control.ts +3 -3
  75. package/src/sdk/bus/telegram-daemon.ts +23 -15
  76. package/src/sdk/client/discovery.ts +10 -4
  77. package/src/sdk/host/query/handlers.ts +20 -1
  78. package/src/sdk/index.ts +1 -0
  79. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  80. package/src/sdk/protocol/operation-registry.ts +5 -2
  81. package/src/sdk/providers.ts +51 -0
  82. package/src/sdk/session-directory.ts +1 -0
  83. package/src/sdk/session.ts +10 -7
  84. package/src/session/agent-session.ts +44 -24
  85. package/src/session/internal/managed-session-scope.ts +981 -450
  86. package/src/session/internal/managed-session-storage.ts +382 -45
  87. package/src/session/internal/native-publish-outcome.ts +3 -1
  88. package/src/session/internal/session-open-errors.ts +11 -0
  89. package/src/session/session-manager.ts +27 -3
  90. package/src/session/session-storage.ts +72 -5
  91. package/src/slash-commands/builtin-registry.ts +82 -4
  92. package/src/task/index.ts +9 -3
  93. package/src/tools/ask.ts +1 -1
  94. package/src/tools/bash.ts +1 -1
  95. package/src/tools/telegram-send.ts +4 -2
@@ -11,7 +11,9 @@ import {
11
11
  TabBar,
12
12
  Text,
13
13
  type TUI,
14
+ truncateToWidth,
14
15
  } from "@gajae-code/tui";
16
+ import { sanitizeText } from "@gajae-code/utils";
15
17
  import {
16
18
  getModelProfilePresentation,
17
19
  groupModelProfilesForPresetLanding,
@@ -96,6 +98,26 @@ interface RoleAssignment {
96
98
  thinkingLevel: ThinkingLevel;
97
99
  }
98
100
 
101
+ type RoleAssignments = Record<string, RoleAssignment | undefined>;
102
+
103
+ interface MaterializedCatalog {
104
+ models: ModelItem[];
105
+ canonicalModels: CanonicalModelItem[];
106
+ roles: RoleAssignments;
107
+ }
108
+
109
+ interface ModelSelectorViewSnapshot {
110
+ roles: RoleAssignments;
111
+ allModels: ModelItem[];
112
+ filteredModels: ModelItem[];
113
+ canonicalModels: CanonicalModelItem[];
114
+ filteredCanonicalModels: CanonicalModelItem[];
115
+ selectedIndex: number;
116
+ providers: ProviderTabState[];
117
+ activeTabIndex: number;
118
+ tabBar: TabBar | null;
119
+ }
120
+
99
121
  export type ModelSelectorSelection =
100
122
  | {
101
123
  kind: "assignment";
@@ -246,6 +268,9 @@ function isInheritedRoleSelector(value: string): boolean {
246
268
  return value === "default" || value === "pi/default";
247
269
  }
248
270
 
271
+ /** Width bound for an unresolvable selector echoed back into the assignment menu. */
272
+ const ROLE_BINDING_MAX_WIDTH = 48;
273
+
249
274
  function getDefaultAliasThinkingLevel(value: string | undefined): ThinkingLevel | undefined {
250
275
  const normalized = value?.trim();
251
276
  if (!normalized?.startsWith("pi/default:")) return undefined;
@@ -327,7 +352,7 @@ export class ModelSelectorComponent extends Container {
327
352
  #canonicalModels: CanonicalModelItem[] = [];
328
353
  #filteredCanonicalModels: CanonicalModelItem[] = [];
329
354
  #selectedIndex: number = 0;
330
- #roles = {} as Record<string, RoleAssignment | undefined>;
355
+ #roles: RoleAssignments = {};
331
356
  #settings = null as unknown as Settings;
332
357
  #modelRegistry = null as unknown as ModelRegistry;
333
358
  #onSelectCallback = (() => {}) as RoleSelectCallback;
@@ -412,7 +437,7 @@ export class ModelSelectorComponent extends Container {
412
437
  this.#viewMode = this.#temporaryOnly || initialSearchInput || scopedModels.length > 0 ? "models" : "presets";
413
438
 
414
439
  // Load current role assignments from settings
415
- this.#loadRoleModels();
440
+ this.#rebuildRoleModels();
416
441
 
417
442
  // Add top border
418
443
  this.addChild(new DynamicBorder());
@@ -495,7 +520,8 @@ export class ModelSelectorComponent extends Container {
495
520
  });
496
521
  }
497
522
 
498
- #loadRoleModels(): void {
523
+ #loadRoleModels(): RoleAssignments {
524
+ const roles: RoleAssignments = {};
499
525
  const allModels = this.#modelRegistry.getAll();
500
526
  const matchPreferences = { usageOrder: this.#settings.getStorage()?.getModelUsageOrder() };
501
527
  const agentModelOverrides = this.#settings.get("task.agentModelOverrides");
@@ -511,7 +537,7 @@ export class ModelSelectorComponent extends Container {
511
537
  modelRegistry: this.#modelRegistry,
512
538
  });
513
539
  if (resolved.model) {
514
- this.#roles[role] = {
540
+ roles[role] = {
515
541
  model: resolved.model,
516
542
  thinkingLevel:
517
543
  resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined
@@ -526,11 +552,22 @@ export class ModelSelectorComponent extends Container {
526
552
  this.#isActiveDefaultFallback()) &&
527
553
  this.#currentModel
528
554
  ) {
529
- this.#roles.default = {
555
+ roles.default = {
530
556
  model: this.#currentModel,
531
557
  thinkingLevel: this.#currentThinkingLevel ?? ThinkingLevel.Inherit,
532
558
  };
533
559
  }
560
+ return roles;
561
+ }
562
+
563
+ /**
564
+ * Re-resolve every role binding against the CURRENT model catalog. Role bindings
565
+ * are resolved snapshots, so any catalog change (initial async load, provider
566
+ * refresh) must rebuild them or badges, ranking, and the assignment menu keep
567
+ * reporting models that the catalog has since gained or lost.
568
+ */
569
+ #rebuildRoleModels(): void {
570
+ this.#roles = this.#loadRoleModels();
534
571
  }
535
572
 
536
573
  refreshRoleAssignments(
@@ -539,9 +576,7 @@ export class ModelSelectorComponent extends Container {
539
576
  if ("currentModel" in options) this.#currentModel = options.currentModel;
540
577
  if ("currentThinkingLevel" in options) this.#currentThinkingLevel = options.currentThinkingLevel;
541
578
  if ("activeModelProfile" in options) this.#activeModelProfile = options.activeModelProfile;
542
- this.#roles = {};
543
- this.#loadRoleModels();
544
- this.#applyTabFilter();
579
+ this.#refreshCatalogView();
545
580
  }
546
581
 
547
582
  #resolveProviderAuthState(providerId: string): ProviderAuthState {
@@ -550,7 +585,7 @@ export class ModelSelectorComponent extends Container {
550
585
  return this.#modelRegistry.hasConfiguredProviderAuth(providerId) ? "configured" : "none";
551
586
  }
552
587
 
553
- #sortModels(models: ModelItem[]): void {
588
+ #sortModels(models: ModelItem[], roles: RoleAssignments = this.#roles): void {
554
589
  // Sort: default-tagged model first, then MRU, then provider ranking
555
590
  const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? [];
556
591
  const mruIndex = new Map(mruOrder.map((key, i) => [key, i]));
@@ -561,7 +596,7 @@ export class ModelSelectorComponent extends Container {
561
596
  }
562
597
  }
563
598
 
564
- const modelRank = (item: ModelItem) => computeModelRank(item.model, this.#roles);
599
+ const modelRank = (item: ModelItem) => computeModelRank(item.model, roles);
565
600
 
566
601
  const dateRe = /-(\d{8})$/;
567
602
  const latestRe = /-latest$/;
@@ -630,7 +665,7 @@ export class ModelSelectorComponent extends Container {
630
665
  });
631
666
  }
632
667
 
633
- #sortCanonicalModels(models: CanonicalModelItem[]): void {
668
+ #sortCanonicalModels(models: CanonicalModelItem[], roles: RoleAssignments = this.#roles): void {
634
669
  const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? [];
635
670
  const mruIndex = new Map(mruOrder.map((key, i) => [key, i]));
636
671
  const providerAuthStateById = new Map<string, ProviderAuthState>();
@@ -640,7 +675,7 @@ export class ModelSelectorComponent extends Container {
640
675
  }
641
676
  }
642
677
 
643
- const modelRank = (item: CanonicalModelItem) => computeModelRank(item.model, this.#roles);
678
+ const modelRank = (item: CanonicalModelItem) => computeModelRank(item.model, roles);
644
679
 
645
680
  models.sort((a, b) => {
646
681
  const aRank = modelRank(a);
@@ -669,23 +704,40 @@ export class ModelSelectorComponent extends Container {
669
704
  });
670
705
  }
671
706
 
672
- async #loadModels(): Promise<void> {
707
+ #buildScopedModelItems(): ModelItem[] {
708
+ return this.#scopedModels.map(scoped => ({
709
+ kind: "provider",
710
+ provider: scoped.model.provider,
711
+ id: scoped.model.id,
712
+ model: scoped.model,
713
+ selector: `${scoped.model.provider}/${scoped.model.id}`,
714
+ thinkingLevel: scoped.thinkingLevel,
715
+ explicitThinkingLevel: scoped.explicitThinkingLevel,
716
+ }));
717
+ }
718
+
719
+ #buildAvailableModelItems(): ModelItem[] {
720
+ return this.#modelRegistry.getAvailable().map((model: Model) => ({
721
+ kind: "provider",
722
+ provider: model.provider,
723
+ id: model.id,
724
+ model,
725
+ selector: `${model.provider}/${model.id}`,
726
+ }));
727
+ }
728
+ async #loadModels(
729
+ options: { refreshRegistry?: boolean; throwOnCatalogError?: boolean; commit?: boolean } = {},
730
+ ): Promise<MaterializedCatalog | undefined> {
673
731
  let models: ModelItem[];
674
732
 
675
733
  // Use scoped models if provided via --models flag
676
734
  if (this.#scopedModels.length > 0) {
677
- models = this.#scopedModels.map(scoped => ({
678
- kind: "provider",
679
- provider: scoped.model.provider,
680
- id: scoped.model.id,
681
- model: scoped.model,
682
- selector: `${scoped.model.provider}/${scoped.model.id}`,
683
- thinkingLevel: scoped.thinkingLevel,
684
- explicitThinkingLevel: scoped.explicitThinkingLevel,
685
- }));
735
+ models = this.#buildScopedModelItems();
686
736
  } else {
687
737
  // Reload config and cached discovery state without blocking on live provider refresh
688
- await this.#modelRegistry.refresh("offline");
738
+ if (options.refreshRegistry !== false) {
739
+ await this.#modelRegistry.refresh("offline");
740
+ }
689
741
 
690
742
  // Check for models.json errors
691
743
  const loadError = this.#modelRegistry.getError();
@@ -697,24 +749,25 @@ export class ModelSelectorComponent extends Container {
697
749
 
698
750
  // Load available models (built-in models still work even if models.json failed)
699
751
  try {
700
- const availableModels = this.#modelRegistry.getAvailable();
701
- models = availableModels.map((model: Model) => ({
702
- kind: "provider",
703
- provider: model.provider,
704
- id: model.id,
705
- model,
706
- selector: `${model.provider}/${model.id}`,
707
- }));
752
+ models = this.#buildAvailableModelItems();
708
753
  } catch (error) {
754
+ if (options.throwOnCatalogError) throw error;
709
755
  this.#allModels = [];
710
756
  this.#filteredModels = [];
711
757
  this.#canonicalModels = [];
712
758
  this.#filteredCanonicalModels = [];
713
759
  this.#errorMessage = error instanceof Error ? error.message : String(error);
760
+ this.#rebuildRoleModels();
714
761
  return;
715
762
  }
716
763
  }
717
764
 
765
+ const catalog = this.#materializeModels(models);
766
+ if (options.commit !== false) this.#commitMaterializedCatalog(catalog);
767
+ return catalog;
768
+ }
769
+
770
+ #materializeModels(models: ModelItem[]): MaterializedCatalog {
718
771
  const candidateModels = models.map(item => item.model);
719
772
  const canonicalRecords = this.#modelRegistry.getCanonicalModels({
720
773
  availableOnly: this.#scopedModels.length === 0,
@@ -758,20 +811,76 @@ export class ModelSelectorComponent extends Container {
758
811
  return item;
759
812
  })
760
813
  .filter((item): item is CanonicalModelItem => item !== undefined);
814
+ const roles = this.#loadRoleModels();
761
815
 
762
- this.#sortModels(models);
763
- this.#sortCanonicalModels(canonicalModels);
764
- this.#allModels = models;
765
- this.#filteredModels = models;
766
- this.#canonicalModels = canonicalModels;
767
- this.#filteredCanonicalModels = canonicalModels;
768
- this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, models.length - 1));
816
+ this.#sortModels(models, roles);
817
+ this.#sortCanonicalModels(canonicalModels, roles);
818
+ return { models, canonicalModels, roles };
819
+ }
820
+
821
+ #commitMaterializedCatalog(catalog: MaterializedCatalog): void {
822
+ this.#roles = catalog.roles;
823
+ this.#allModels = catalog.models;
824
+ this.#filteredModels = catalog.models;
825
+ this.#canonicalModels = catalog.canonicalModels;
826
+ this.#filteredCanonicalModels = catalog.canonicalModels;
827
+ this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, catalog.models.length - 1));
828
+ }
829
+
830
+ #captureViewSnapshot(): ModelSelectorViewSnapshot {
831
+ return {
832
+ roles: this.#roles,
833
+ allModels: this.#allModels,
834
+ filteredModels: this.#filteredModels,
835
+ canonicalModels: this.#canonicalModels,
836
+ filteredCanonicalModels: this.#filteredCanonicalModels,
837
+ selectedIndex: this.#selectedIndex,
838
+ providers: this.#providers,
839
+ activeTabIndex: this.#activeTabIndex,
840
+ tabBar: this.#tabBar,
841
+ };
842
+ }
843
+
844
+ #restoreViewSnapshot(snapshot: ModelSelectorViewSnapshot): void {
845
+ this.#roles = snapshot.roles;
846
+ this.#allModels = snapshot.allModels;
847
+ this.#filteredModels = snapshot.filteredModels;
848
+ this.#canonicalModels = snapshot.canonicalModels;
849
+ this.#filteredCanonicalModels = snapshot.filteredCanonicalModels;
850
+ this.#selectedIndex = snapshot.selectedIndex;
851
+ this.#providers = snapshot.providers;
852
+ this.#activeTabIndex = snapshot.activeTabIndex;
853
+ this.#tabBar = snapshot.tabBar;
854
+ }
855
+
856
+ #refreshCatalogView(): boolean {
857
+ const previousView = this.#captureViewSnapshot();
858
+ try {
859
+ const models =
860
+ this.#scopedModels.length > 0 ? this.#buildScopedModelItems() : this.#buildAvailableModelItems();
861
+ const catalog = this.#materializeModels(models);
862
+ this.#buildProviderTabs(catalog.models);
863
+ this.#commitMaterializedCatalog(catalog);
864
+ this.#updateTabBar();
865
+ this.#applyTabFilter();
866
+ return true;
867
+ } catch (error) {
868
+ this.#errorMessage = error instanceof Error ? error.message : String(error);
869
+ this.#restoreViewSnapshot(previousView);
870
+ try {
871
+ this.#updateTabBar();
872
+ this.#applyTabFilter();
873
+ } catch {
874
+ // Keep the last-good component state even if the terminal view cannot be rebuilt.
875
+ }
876
+ return false;
877
+ }
769
878
  }
770
879
 
771
- #buildProviderTabs(): void {
880
+ #buildProviderTabs(models: readonly ModelItem[] = this.#allModels): void {
772
881
  const activeTabId = this.#getActiveTab().id;
773
882
  const providerSet = new Set<string>();
774
- for (const item of this.#allModels) {
883
+ for (const item of models) {
775
884
  providerSet.add(item.provider);
776
885
  }
777
886
  for (const provider of this.#modelRegistry.getDiscoverableProviders()) {
@@ -806,12 +915,58 @@ export class ModelSelectorComponent extends Container {
806
915
  if (this.#scopedModels.length > 0 || !providerId) {
807
916
  return;
808
917
  }
809
- await this.#modelRegistry.refreshProvider(providerId);
810
- await this.#loadModels();
811
- this.#buildProviderTabs();
812
- this.#updateTabBar();
813
- this.#applyTabFilter();
814
- this.#tui.requestRender();
918
+
919
+ let refreshError: unknown;
920
+ try {
921
+ await this.#modelRegistry.refreshProvider(providerId);
922
+ } catch (error) {
923
+ refreshError = error;
924
+ }
925
+
926
+ let catalog: MaterializedCatalog | undefined;
927
+ try {
928
+ catalog = await this.#loadModels({
929
+ refreshRegistry: refreshError === undefined,
930
+ throwOnCatalogError: true,
931
+ commit: false,
932
+ });
933
+ } catch (catalogError) {
934
+ if (refreshError !== undefined) {
935
+ const refreshMessage = refreshError instanceof Error ? refreshError.message : String(refreshError);
936
+ const recoveryMessage = catalogError instanceof Error ? catalogError.message : String(catalogError);
937
+ throw new Error(`${refreshMessage}; catalog recovery failed: ${recoveryMessage}`, {
938
+ cause: refreshError,
939
+ });
940
+ }
941
+ throw catalogError;
942
+ }
943
+ if (!catalog) throw new Error("Model catalog could not be materialized.");
944
+
945
+ const previousView = this.#captureViewSnapshot();
946
+ try {
947
+ this.#buildProviderTabs(catalog.models);
948
+ this.#commitMaterializedCatalog(catalog);
949
+ this.#updateTabBar();
950
+ this.#applyTabFilter();
951
+ this.#tui.requestRender();
952
+ } catch (presentationError) {
953
+ const finalError =
954
+ refreshError !== undefined
955
+ ? new Error(
956
+ `${refreshError instanceof Error ? refreshError.message : String(refreshError)}; catalog presentation failed: ${presentationError instanceof Error ? presentationError.message : String(presentationError)}`,
957
+ { cause: refreshError },
958
+ )
959
+ : presentationError;
960
+ this.#restoreViewSnapshot(previousView);
961
+ try {
962
+ this.#updateTabBar();
963
+ this.#applyTabFilter();
964
+ } catch {
965
+ // Preserve the original presentation failure and the last-good component state.
966
+ }
967
+ throw finalError;
968
+ }
969
+ if (refreshError !== undefined) throw refreshError;
815
970
  }
816
971
 
817
972
  #updateTabBar(): void {
@@ -1193,11 +1348,12 @@ export class ModelSelectorComponent extends Container {
1193
1348
  }
1194
1349
 
1195
1350
  #formatAssignedModelLabel(model: Model, thinkingLevel: ThinkingLevel | undefined): string {
1196
- let label = `${model.provider}/${model.id}`;
1351
+ const modelLabel = sanitizeText(`${model.provider}/${model.id}`).replace(/\s+/g, " ").trim();
1352
+ let label = modelLabel;
1197
1353
  if (thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit) {
1198
1354
  label += ` (${getThinkingLevelMetadata(thinkingLevel).label})`;
1199
1355
  }
1200
- return label;
1356
+ return truncateToWidth(label, ROLE_BINDING_MAX_WIDTH);
1201
1357
  }
1202
1358
 
1203
1359
  #renderPresetLanding(): void {
@@ -1510,7 +1666,7 @@ export class ModelSelectorComponent extends Container {
1510
1666
  const prefix = i === this.#selectedActionIndex ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
1511
1667
  const role = GJC_MODEL_ASSIGNMENT_TARGET_IDS[i];
1512
1668
  const label = role
1513
- ? `Set as ${GJC_MODEL_ASSIGNMENT_TARGETS[role].tag ?? role.toUpperCase()} (${GJC_MODEL_ASSIGNMENT_TARGETS[role].name})`
1669
+ ? `Set as ${GJC_MODEL_ASSIGNMENT_TARGETS[role].tag ?? role.toUpperCase()} (${GJC_MODEL_ASSIGNMENT_TARGETS[role].name}) — now: ${this.#formatRoleBinding(role)}`
1514
1670
  : i === GJC_MODEL_ASSIGNMENT_TARGET_IDS.length
1515
1671
  ? "Set for all role agents"
1516
1672
  : "Set for all targets";
@@ -1520,6 +1676,43 @@ export class ModelSelectorComponent extends Container {
1520
1676
  }
1521
1677
  }
1522
1678
 
1679
+ /**
1680
+ * Describe what a role currently points at so the assignment menu can show the
1681
+ * existing binding inline. Without it the role rows carry no binding and the
1682
+ * only way to learn a role's model is to scan the whole (800+ entry) model list
1683
+ * for role badges.
1684
+ */
1685
+ #formatRoleBinding(role: GjcModelAssignmentTargetId): string {
1686
+ const target = GJC_MODEL_ASSIGNMENT_TARGETS[role];
1687
+ const configured =
1688
+ target.settingsPath === "modelRoles"
1689
+ ? this.#settings.getModelRole(role)
1690
+ : this.#settings.get("task.agentModelOverrides")[role];
1691
+ const selectors = normalizeModelSelectorValue(configured);
1692
+ const head = selectors[0];
1693
+ const assigned = this.#roles[role];
1694
+ if (!head) {
1695
+ if (assigned) return this.#formatAssignedModelLabel(assigned.model, assigned.thinkingLevel);
1696
+ return role === "default" ? "unset" : "inherits default";
1697
+ }
1698
+ const inheritedThinkingLevel = getDefaultAliasThinkingLevel(head);
1699
+ const isInheritedAlias = head === "pi/default" || inheritedThinkingLevel !== undefined;
1700
+ // A role agent pinned to the `pi/default` alias inherits whatever the default
1701
+ // resolves to. Classify that before the resolved lookup: the alias itself resolves
1702
+ // to the default's model, which would otherwise render as a concrete id and hide the
1703
+ // inheritance. A fallback chain merely headed by the alias is NOT inherited —
1704
+ // resolution can advance to a tail entry — so it falls through to the resolved
1705
+ // binding below.
1706
+ if (role !== "default" && selectors.length === 1 && isInheritedAlias) {
1707
+ return inheritedThinkingLevel ? `inherits default (${inheritedThinkingLevel})` : "inherits default";
1708
+ }
1709
+ if (assigned) return this.#formatAssignedModelLabel(assigned.model, assigned.thinkingLevel);
1710
+ // Configured selectors are permissively validated, so bound and sanitize the raw
1711
+ // value before it reaches the terminal.
1712
+ const displayed = truncateToWidth(sanitizeText(head).replace(/\s+/g, " ").trim(), ROLE_BINDING_MAX_WIDTH);
1713
+ return `${displayed} (unavailable)`;
1714
+ }
1715
+
1523
1716
  #renderThinkingMenu(choice: PendingThinkingChoice): void {
1524
1717
  const targetLabel = choice.roles
1525
1718
  ? choice.roles.includes("default")