@gajae-code/coding-agent 0.7.8 → 0.7.10

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 (92) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +8 -0
  3. package/dist/types/config/keybindings.d.ts +1 -1
  4. package/dist/types/config/model-profile-activation.d.ts +19 -2
  5. package/dist/types/config/model-profiles.d.ts +1 -0
  6. package/dist/types/config/model-registry.d.ts +4 -0
  7. package/dist/types/config/settings-schema.d.ts +12 -3
  8. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  9. package/dist/types/gjc-runtime/launch-tmux.d.ts +19 -3
  10. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +7 -1
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +0 -4
  12. package/dist/types/modes/components/hook-input.d.ts +1 -0
  13. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  14. package/dist/types/modes/components/model-selector.d.ts +9 -0
  15. package/dist/types/modes/components/status-line.d.ts +3 -0
  16. package/dist/types/modes/controllers/extension-ui-controller.d.ts +3 -1
  17. package/dist/types/modes/interactive-mode.d.ts +3 -1
  18. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +41 -7
  19. package/dist/types/modes/types.d.ts +3 -1
  20. package/dist/types/notifications/daemon-paths.d.ts +9 -0
  21. package/dist/types/notifications/html-format.d.ts +2 -0
  22. package/dist/types/notifications/recent-activity.d.ts +5 -1
  23. package/dist/types/notifications/telegram-daemon-cli.d.ts +8 -2
  24. package/dist/types/notifications/telegram-daemon-control.d.ts +8 -0
  25. package/dist/types/notifications/telegram-daemon.d.ts +1 -10
  26. package/dist/types/notifications/telegram-reference.d.ts +4 -0
  27. package/dist/types/notifications/threaded-render.d.ts +1 -0
  28. package/dist/types/tools/ask.d.ts +26 -0
  29. package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
  30. package/dist/types/utils/cmux-workspace.d.ts +23 -0
  31. package/dist/types/web/search/index.d.ts +1 -1
  32. package/dist/types/web/search/provider.d.ts +5 -0
  33. package/dist/types/web/search/render.d.ts +1 -0
  34. package/package.json +7 -7
  35. package/src/cli/notify-cli.ts +1 -1
  36. package/src/cli/web-search-cli.ts +3 -2
  37. package/src/cli.ts +1 -0
  38. package/src/config/keybindings.ts +1 -1
  39. package/src/config/model-profile-activation.ts +92 -11
  40. package/src/config/model-profiles.ts +15 -2
  41. package/src/config/model-registry.ts +81 -5
  42. package/src/config/settings-schema.ts +14 -3
  43. package/src/coordinator-mcp/server.ts +4 -2
  44. package/src/deep-interview/render-middleware.ts +1 -0
  45. package/src/defaults/gjc/skills/deep-interview/SKILL.md +40 -8
  46. package/src/defaults/gjc/skills/ralplan/SKILL.md +14 -7
  47. package/src/defaults/gjc/skills/ultragoal/SKILL.md +37 -112
  48. package/src/extensibility/extensions/types.ts +11 -0
  49. package/src/gjc-runtime/deep-interview-runtime.ts +1 -1
  50. package/src/gjc-runtime/launch-tmux.ts +366 -48
  51. package/src/gjc-runtime/team-runtime.ts +69 -4
  52. package/src/gjc-runtime/ultragoal-guard.ts +150 -24
  53. package/src/gjc-runtime/ultragoal-runtime.ts +16 -124
  54. package/src/gjc-runtime/workflow-manifest.generated.json +0 -8
  55. package/src/gjc-runtime/workflow-manifest.ts +0 -1
  56. package/src/hooks/skill-state.ts +33 -73
  57. package/src/internal-urls/docs-index.generated.ts +8 -7
  58. package/src/modes/components/custom-model-preset-wizard.ts +2 -4
  59. package/src/modes/components/hook-editor.ts +7 -4
  60. package/src/modes/components/hook-input.ts +4 -0
  61. package/src/modes/components/hook-selector.ts +21 -3
  62. package/src/modes/components/model-selector.ts +84 -13
  63. package/src/modes/components/settings-selector.ts +83 -24
  64. package/src/modes/components/status-line/presets.ts +11 -0
  65. package/src/modes/components/status-line/segments.ts +10 -2
  66. package/src/modes/components/status-line.ts +1 -0
  67. package/src/modes/controllers/extension-ui-controller.ts +20 -2
  68. package/src/modes/controllers/selector-controller.ts +130 -7
  69. package/src/modes/interactive-mode.ts +7 -2
  70. package/src/modes/shared/agent-wire/deep-interview-gate.ts +64 -13
  71. package/src/modes/types.ts +6 -1
  72. package/src/notifications/daemon-paths.ts +22 -0
  73. package/src/notifications/html-format.ts +75 -1
  74. package/src/notifications/index.ts +12 -1
  75. package/src/notifications/recent-activity.ts +32 -8
  76. package/src/notifications/telegram-daemon-cli.ts +13 -4
  77. package/src/notifications/telegram-daemon-control.ts +9 -0
  78. package/src/notifications/telegram-daemon.ts +69 -45
  79. package/src/notifications/telegram-reference.ts +36 -14
  80. package/src/notifications/threaded-render.ts +14 -6
  81. package/src/prompts/system/system-prompt.md +10 -1
  82. package/src/sdk.ts +13 -6
  83. package/src/session/agent-session.ts +10 -1
  84. package/src/tools/ask.ts +147 -17
  85. package/src/tools/eval.ts +2 -2
  86. package/src/tools/render-utils.ts +2 -0
  87. package/src/tools/ultragoal-ask-guard.ts +39 -5
  88. package/src/utils/cmux-workspace.ts +106 -0
  89. package/src/utils/title-generator.ts +2 -0
  90. package/src/web/search/index.ts +22 -10
  91. package/src/web/search/provider.ts +15 -0
  92. package/src/web/search/render.ts +3 -0
@@ -1,11 +1,9 @@
1
1
  import { Container, Input, matchesKey, Spacer, Text, TruncatedText } from "@gajae-code/tui";
2
+ import { MODEL_PROFILE_NAME_PATTERN } from "../../config/model-registry";
2
3
  import type { ModelProfileConfig } from "../../config/models-config-schema";
3
4
  import { theme } from "../theme/theme";
4
5
  import { matchesAppInterrupt } from "../utils/keybinding-matchers";
5
6
  import { DynamicBorder } from "./dynamic-border";
6
-
7
- const PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
8
-
9
7
  export interface CustomModelPresetWizardSubmit {
10
8
  name: string;
11
9
  profile: ModelProfileConfig;
@@ -113,7 +111,7 @@ export class CustomModelPresetWizardComponent extends Container {
113
111
  this.#onRender();
114
112
  return;
115
113
  }
116
- if (!PROFILE_NAME_PATTERN.test(value)) {
114
+ if (!MODEL_PROFILE_NAME_PATTERN.test(value)) {
117
115
  this.#lastError = "Preset id must use lowercase letters, numbers, dots, underscores, or hyphens.";
118
116
  this.#renderStep();
119
117
  this.#onRender();
@@ -96,14 +96,17 @@ export class HookEditorComponent extends Container {
96
96
  return;
97
97
  }
98
98
 
99
- // Submit on plain Enter encodings. On Windows, raw LF is reserved for terminal
100
- // newline mappings (Shift+Enter/Ctrl+J/Ctrl+Enter); plain Enter reports CR.
101
- if (!isWindowsRawLfNewlineInput(keyData) && (matchesKey(keyData, "enter") || matchesKey(keyData, "return"))) {
99
+ // Submit on Enter and Ctrl+Enter encodings. On Windows, raw LF is reserved for
100
+ // terminal newline mappings (Shift+Enter/Ctrl+J); plain Enter reports CR.
101
+ if (
102
+ !isWindowsRawLfNewlineInput(keyData) &&
103
+ (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || matchesKey(keyData, "ctrl+enter"))
104
+ ) {
102
105
  this.#onSubmitCallback(this.#editor.getText());
103
106
  return;
104
107
  }
105
108
 
106
- // Let Editor handle modified newline-producing variants (Shift+Enter, Ctrl+Enter, Alt+Enter, etc.)
109
+ // Let Editor handle newline-producing variants (Shift+Enter, Ctrl+J, Alt+Enter, etc.)
107
110
  this.#editor.handleInput(keyData);
108
111
  }
109
112
 
@@ -8,6 +8,7 @@ import { CountdownTimer } from "./countdown-timer";
8
8
  import { DynamicBorder } from "./dynamic-border";
9
9
 
10
10
  export interface HookInputOptions {
11
+ initialValue?: string;
11
12
  tui?: TUI;
12
13
  timeout?: number;
13
14
  onTimeout?: () => void;
@@ -54,6 +55,9 @@ export class HookInputComponent extends Container {
54
55
  }
55
56
 
56
57
  this.#input = new Input();
58
+ if (opts?.initialValue) {
59
+ this.#input.setValue(opts.initialValue);
60
+ }
57
61
  this.addChild(this.#input);
58
62
  this.addChild(new Spacer(1));
59
63
  this.addChild(new Text(theme.fg("dim", "enter submit esc cancel"), 1, 0));
@@ -58,6 +58,11 @@ export interface HookSelectorOptions {
58
58
  optionLabel: string;
59
59
  onSubmit: (text: string) => void;
60
60
  };
61
+ clarificationInput?: {
62
+ optionLabel: string;
63
+ onSubmit: (text: string) => void;
64
+ allowEmpty?: boolean;
65
+ };
61
66
  /**
62
67
  * Autocomplete provider for the inline custom-input editor. When present,
63
68
  * the "Other (type your own)" editor gains the same `@` file-link and `/`
@@ -338,6 +343,8 @@ export class HookSelectorComponent extends Container {
338
343
  #outline: boolean;
339
344
  #scrollTitleRows: number | undefined;
340
345
  #customInput: { optionLabel: string; onSubmit: (text: string) => void } | undefined;
346
+ #clarificationInput: { optionLabel: string; onSubmit: (text: string) => void; allowEmpty?: boolean } | undefined;
347
+ #activeInput: { onSubmit: (text: string) => void; allowEmpty?: boolean } | undefined;
341
348
  #inputArea: Container;
342
349
  #inlineEditor: Editor | undefined;
343
350
  #helpTextComponent: Text;
@@ -365,6 +372,7 @@ export class HookSelectorComponent extends Container {
365
372
  this.#wrapFocused = opts?.wrapFocused === true;
366
373
  this.#outline = opts?.outline === true;
367
374
  this.#customInput = opts?.customInput;
375
+ this.#clarificationInput = opts?.clarificationInput;
368
376
  this.#tui = opts?.tui;
369
377
  this.#autocompleteProvider = opts?.autocompleteProvider;
370
378
 
@@ -500,7 +508,11 @@ export class HookSelectorComponent extends Container {
500
508
  const selected = this.#options[this.#selectedIndex];
501
509
  if (!selected) return;
502
510
  if (this.#customInput && selected === this.#customInput.optionLabel) {
503
- this.#enterInputMode();
511
+ this.#enterInputMode(this.#customInput);
512
+ return;
513
+ }
514
+ if (this.#clarificationInput && selected === this.#clarificationInput.optionLabel) {
515
+ this.#enterInputMode(this.#clarificationInput);
504
516
  return;
505
517
  }
506
518
  this.#onSelectCallback(selected);
@@ -535,14 +547,19 @@ export class HookSelectorComponent extends Container {
535
547
  return;
536
548
  }
537
549
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return")) {
538
- this.#customInput?.onSubmit(editor.getExpandedText());
550
+ const text = editor.getExpandedText();
551
+ if (this.#activeInput?.allowEmpty === false && text.trim() === "") {
552
+ return;
553
+ }
554
+ this.#activeInput?.onSubmit(text);
539
555
  return;
540
556
  }
541
557
  editor.handleInput(keyData);
542
558
  }
543
559
 
544
- #enterInputMode(): void {
560
+ #enterInputMode(input: { onSubmit: (text: string) => void; allowEmpty?: boolean }): void {
545
561
  if (this.#inlineEditor) return;
562
+ this.#activeInput = input;
546
563
  // Stop the auto-select countdown for good: the user is actively typing,
547
564
  // matching the old behavior where the separate editor had no timeout.
548
565
  if (this.#countdown) {
@@ -577,6 +594,7 @@ export class HookSelectorComponent extends Container {
577
594
  #exitInputMode(): void {
578
595
  if (!this.#inlineEditor) return;
579
596
  this.#inlineEditor = undefined;
597
+ this.#activeInput = undefined;
580
598
  this.#inputArea.clear();
581
599
  this.#helpTextComponent.setText(theme.fg("dim", this.#baseHelpText));
582
600
  this.invalidate();
@@ -107,6 +107,14 @@ export type ModelSelectorSelection =
107
107
  | {
108
108
  kind: "createProfile";
109
109
  profile: ModelProfileConfig;
110
+ }
111
+ | {
112
+ kind: "renameProfile";
113
+ profileName: string;
114
+ }
115
+ | {
116
+ kind: "deleteProfile";
117
+ profileName: string;
110
118
  };
111
119
 
112
120
  interface PendingThinkingChoice {
@@ -164,7 +172,7 @@ interface PresetCreateUnavailableRow {
164
172
 
165
173
  interface PresetAlreadySavedRow {
166
174
  kind: "alreadySaved";
167
- profileName: string;
175
+ profile: ModelProfileDefinition;
168
176
  }
169
177
 
170
178
  interface PresetBrowseRow {
@@ -195,7 +203,7 @@ function presetRowIdentity(row: PresetLandingRow): string {
195
203
  case "createUnavailable":
196
204
  return "createUnavailable";
197
205
  case "alreadySaved":
198
- return `alreadySaved:${row.profileName}`;
206
+ return `alreadySaved:${row.profile.name}`;
199
207
  }
200
208
  }
201
209
 
@@ -207,6 +215,7 @@ const PROFILE_ROLE_PREVIEW_ORDER: GjcModelAssignmentTargetId[] = [
207
215
  "architect",
208
216
  ];
209
217
  const PRESET_SCOPE_LABELS = ["Apply for this session", "Set as default"];
218
+ const CUSTOM_PRESET_SCOPE_LABELS = ["Apply for this session", "Set as default", "Rename", "Delete"];
210
219
 
211
220
  function isPrintableCharacter(keyData: string): boolean {
212
221
  return keyData.length === 1 && keyData >= " " && keyData !== "\x7f";
@@ -273,6 +282,10 @@ function sameStringArray(left: readonly string[], right: readonly string[]): boo
273
282
  function hasPersistableProfileSnapshot(snapshot: ModelProfileConfig): boolean {
274
283
  return Object.keys(snapshot.model_mapping).length > 0 && snapshot.required_providers.length > 0;
275
284
  }
285
+
286
+ function isCustomUserProfile(profile: ModelProfileDefinition): boolean {
287
+ return profile.source === "user";
288
+ }
276
289
  /**
277
290
  * Component that renders a canonical model selector with provider tabs.
278
291
  * - Preset landing Left/Right: Collapse/expand selected provider
@@ -885,9 +898,7 @@ export class ModelSelectorComponent extends Container {
885
898
  const snapshot = this.#buildCustomModelProfileSnapshot();
886
899
  if (hasPersistableProfileSnapshot(snapshot)) {
887
900
  const duplicateProfile = this.#findDuplicateGeneratedProfile(snapshot);
888
- rows.push(
889
- duplicateProfile ? { kind: "alreadySaved", profileName: duplicateProfile.name } : { kind: "create" },
890
- );
901
+ rows.push(duplicateProfile ? { kind: "alreadySaved", profile: duplicateProfile } : { kind: "create" });
891
902
  } else {
892
903
  rows.push({ kind: "createUnavailable", label: "Select a model before creating a custom preset" });
893
904
  }
@@ -961,7 +972,14 @@ export class ModelSelectorComponent extends Container {
961
972
  this.#presetCursor = relocated;
962
973
  return true;
963
974
  }
964
-
975
+ #relocatePresetCursorForProfile(profileName: string): boolean {
976
+ for (const [groupId, profiles] of this.#getPresetGroups()) {
977
+ if (!profiles.some(profile => profile.name === profileName)) continue;
978
+ this.#expandedPresetProviderId = groupId;
979
+ return this.#relocatePresetCursor(`profile:${groupId}:${profileName}`);
980
+ }
981
+ return false;
982
+ }
965
983
  #expandSelectedPresetProvider(): void {
966
984
  const selected = this.#getSelectedPresetRow();
967
985
  if (
@@ -1029,7 +1047,8 @@ export class ModelSelectorComponent extends Container {
1029
1047
  continue;
1030
1048
  }
1031
1049
  if (row.kind === "alreadySaved") {
1032
- const label = `Already saved as ${row.profileName}`;
1050
+ const presentation = getModelProfilePresentation(row.profile);
1051
+ const label = `Already saved as ${presentation.displayName}`;
1033
1052
  const renderedLabel = selected ? theme.fg("accent", label) : theme.fg("dim", label);
1034
1053
  this.#listContainer.addChild(new Text(`${prefix}${renderedLabel}`, 0, 0));
1035
1054
  continue;
@@ -1082,8 +1101,9 @@ export class ModelSelectorComponent extends Container {
1082
1101
  }
1083
1102
  this.#listContainer.addChild(new Spacer(1));
1084
1103
  if (this.#presetScopeMenuOpen) {
1085
- for (let i = 0; i < PRESET_SCOPE_LABELS.length; i++) {
1086
- const label = PRESET_SCOPE_LABELS[i] ?? "";
1104
+ const actionLabels = this.#getPresetScopeLabels(profile);
1105
+ for (let i = 0; i < actionLabels.length; i++) {
1106
+ const label = actionLabels[i] ?? "";
1087
1107
  const prefix = i === this.#presetScopeIndex ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
1088
1108
  this.#listContainer.addChild(
1089
1109
  new Text(`${prefix}${i === this.#presetScopeIndex ? theme.fg("accent", label) : label}`, 0, 0),
@@ -1394,8 +1414,8 @@ export class ModelSelectorComponent extends Container {
1394
1414
  const rows = this.#getPresetRows();
1395
1415
  if (rows.length === 0) return;
1396
1416
  if (this.#presetScopeMenuOpen) {
1397
- this.#presetScopeIndex =
1398
- this.#presetScopeIndex === 0 ? PRESET_SCOPE_LABELS.length - 1 : this.#presetScopeIndex - 1;
1417
+ const actionCount = this.#getPreviewPresetScopeLabels().length;
1418
+ this.#presetScopeIndex = this.#presetScopeIndex === 0 ? actionCount - 1 : this.#presetScopeIndex - 1;
1399
1419
  } else {
1400
1420
  this.#presetCursor = this.#presetCursor === 0 ? rows.length - 1 : this.#presetCursor - 1;
1401
1421
  this.#previewProfileName = undefined;
@@ -1409,7 +1429,7 @@ export class ModelSelectorComponent extends Container {
1409
1429
  const rows = this.#getPresetRows();
1410
1430
  if (rows.length === 0) return;
1411
1431
  if (this.#presetScopeMenuOpen) {
1412
- this.#presetScopeIndex = (this.#presetScopeIndex + 1) % PRESET_SCOPE_LABELS.length;
1432
+ this.#presetScopeIndex = (this.#presetScopeIndex + 1) % this.#getPreviewPresetScopeLabels().length;
1413
1433
  } else {
1414
1434
  this.#presetCursor = (this.#presetCursor + 1) % rows.length;
1415
1435
  this.#previewProfileName = undefined;
@@ -1464,6 +1484,22 @@ export class ModelSelectorComponent extends Container {
1464
1484
 
1465
1485
  #handlePresetEnter(): void {
1466
1486
  if (this.#presetScopeMenuOpen && this.#previewProfileName) {
1487
+ const profile = this.#getProfileByName(this.#previewProfileName);
1488
+ if (!profile) return;
1489
+ if (this.#presetScopeIndex === 2 && isCustomUserProfile(profile)) {
1490
+ this.#onSelectCallback({ kind: "renameProfile", profileName: this.#previewProfileName });
1491
+ return;
1492
+ }
1493
+ if (this.#presetScopeIndex === 3 && isCustomUserProfile(profile)) {
1494
+ this.#onSelectCallback({ kind: "deleteProfile", profileName: this.#previewProfileName });
1495
+ return;
1496
+ }
1497
+ const missing = this.#getMissingProviders(profile);
1498
+ if (missing.length > 0) {
1499
+ this.#presetLoginHint = `Run ${missing.map(provider => `/login ${provider}`).join(", ")}`;
1500
+ this.#renderPresetLanding();
1501
+ return;
1502
+ }
1467
1503
  this.#onSelectCallback({
1468
1504
  kind: "profile",
1469
1505
  profileName: this.#previewProfileName,
@@ -1502,7 +1538,7 @@ export class ModelSelectorComponent extends Container {
1502
1538
  return;
1503
1539
  }
1504
1540
  const missing = this.#getMissingProviders(row.profile);
1505
- if (missing.length > 0) {
1541
+ if (missing.length > 0 && !isCustomUserProfile(row.profile)) {
1506
1542
  this.#presetLoginHint = `Run ${missing.map(provider => `/login ${provider}`).join(", ")}`;
1507
1543
  this.#renderPresetLanding();
1508
1544
  return;
@@ -1512,6 +1548,31 @@ export class ModelSelectorComponent extends Container {
1512
1548
  this.#renderPresetLanding();
1513
1549
  }
1514
1550
 
1551
+ #getPresetScopeLabels(profile: ModelProfileDefinition): string[] {
1552
+ return isCustomUserProfile(profile) ? CUSTOM_PRESET_SCOPE_LABELS : PRESET_SCOPE_LABELS;
1553
+ }
1554
+
1555
+ #getPreviewPresetScopeLabels(): string[] {
1556
+ const profile = this.#getProfileByName(this.#previewProfileName);
1557
+ return profile ? this.#getPresetScopeLabels(profile) : PRESET_SCOPE_LABELS;
1558
+ }
1559
+
1560
+ refreshPresetProfiles(profileName?: string): void {
1561
+ if (profileName) {
1562
+ this.#previewProfileName = profileName;
1563
+ this.#presetLoginHint = undefined;
1564
+ if (!this.#relocatePresetCursorForProfile(profileName)) this.#clampPresetCursor();
1565
+ } else {
1566
+ this.#previewProfileName = undefined;
1567
+ this.#presetScopeMenuOpen = false;
1568
+ this.#presetScopeIndex = 0;
1569
+ this.#presetLoginHint = undefined;
1570
+ this.#clampPresetCursor();
1571
+ }
1572
+ this.#renderPresetLanding();
1573
+ this.#tui.requestRender();
1574
+ }
1575
+
1515
1576
  #beginActionMenuOrSelect(item: ModelItem | CanonicalModelItem): void {
1516
1577
  if (this.#temporaryOnly) {
1517
1578
  this.#handleSelect(item, null);
@@ -1631,6 +1692,16 @@ export class ModelSelectorComponent extends Container {
1631
1692
  async __testSelectProfile(profileName: string, setDefault: boolean): Promise<void> {
1632
1693
  await this.#onSelectCallback({ kind: "profile", profileName, setDefault });
1633
1694
  }
1695
+ async __testSelectPresetAction(profileName: string, action: "rename" | "delete"): Promise<void> {
1696
+ await this.#onSelectCallback({
1697
+ kind: action === "rename" ? "renameProfile" : "deleteProfile",
1698
+ profileName,
1699
+ });
1700
+ }
1701
+ __testSelectedPresetRowIdentity(): string | undefined {
1702
+ const row = this.#getSelectedPresetRow();
1703
+ return row ? presetRowIdentity(row) : undefined;
1704
+ }
1634
1705
  }
1635
1706
 
1636
1707
  function requiresExplicitThinkingChoice(model: Model): boolean {
@@ -164,6 +164,7 @@ class SelectSubmenu extends Container {
164
164
  }
165
165
  }
166
166
  const STATUS_LINE_CUSTOM_EDITOR_ID = "statusLine.customEditor";
167
+ const STATUS_LINE_USAGE_MODE_ID = "statusLine.usageMode";
167
168
  const PUBLIC_STATUS_SEGMENTS = ALL_SEGMENT_IDS.filter(id => id !== "pi");
168
169
 
169
170
  type StatusLineDraft = Required<
@@ -179,6 +180,12 @@ const TIME_FORMAT_OPTIONS: SelectItem[] = [
179
180
  { value: "24h", label: "24h" },
180
181
  { value: "12h", label: "12h" },
181
182
  ];
183
+ const USAGE_MODE_OPTIONS: SelectItem[] = [
184
+ { value: "used", label: "Used" },
185
+ { value: "remaining", label: "Remaining" },
186
+ ];
187
+ const USAGE_MODE_VALUES = ["used", "remaining"] as const;
188
+ type UsageMode = (typeof USAGE_MODE_VALUES)[number];
182
189
 
183
190
  function cloneSegmentOptions(options: StatusLineSegmentOptions | undefined): StatusLineSegmentOptions {
184
191
  return mergeSegmentOptions(undefined, options);
@@ -193,6 +200,7 @@ function mergeSegmentOptions(
193
200
  path: base?.path || overrides?.path ? { ...(base?.path ?? {}), ...(overrides?.path ?? {}) } : undefined,
194
201
  git: base?.git || overrides?.git ? { ...(base?.git ?? {}), ...(overrides?.git ?? {}) } : undefined,
195
202
  time: base?.time || overrides?.time ? { ...(base?.time ?? {}), ...(overrides?.time ?? {}) } : undefined,
203
+ usage: base?.usage || overrides?.usage ? { ...(base?.usage ?? {}), ...(overrides?.usage ?? {}) } : undefined,
196
204
  };
197
205
  }
198
206
 
@@ -218,6 +226,25 @@ function effectiveCustomSegments(
218
226
  };
219
227
  }
220
228
 
229
+ function getSavedUsageMode(): UsageMode {
230
+ const segmentOptions = settings.get("statusLine.segmentOptions") as StatusLineSegmentOptions;
231
+ return segmentOptions.usage?.mode === "remaining" ? "remaining" : "used";
232
+ }
233
+
234
+ function setSavedUsageMode(mode: string): StatusLineSegmentOptions {
235
+ const normalizedMode: UsageMode = mode === "remaining" ? "remaining" : "used";
236
+ const segmentOptions = settings.get("statusLine.segmentOptions") as StatusLineSegmentOptions;
237
+ const nextOptions: StatusLineSegmentOptions = {
238
+ ...segmentOptions,
239
+ usage: {
240
+ ...(segmentOptions.usage ?? {}),
241
+ mode: normalizedMode,
242
+ },
243
+ };
244
+ settings.set("statusLine.segmentOptions", nextOptions as Record<string, unknown>);
245
+ return nextOptions;
246
+ }
247
+
221
248
  function statusSegmentLabel(id: StatusLineSegmentId): string {
222
249
  return id.replace(/_/g, " ");
223
250
  }
@@ -235,8 +262,6 @@ function segmentPlacement(
235
262
  class StatusLineCustomEditor extends Container {
236
263
  #list!: SettingsList;
237
264
  #draft: StatusLineDraft;
238
- #currentWidthPreviewText!: Text;
239
- #narrowWidthPreviewText!: Text;
240
265
  #previewHighlightSegment: StatusLineSegmentId | undefined;
241
266
 
242
267
  constructor(
@@ -268,13 +293,6 @@ class StatusLineCustomEditor extends Container {
268
293
  this.clear();
269
294
  this.addChild(new Text(theme.bold(theme.fg("accent", "Status Line Custom Editor")), 0, 0));
270
295
  this.addChild(new Spacer(1));
271
- this.addChild(new Text(theme.fg("muted", "Current width preview:"), 0, 0));
272
- this.#currentWidthPreviewText = new Text(this.#statusLinePreview(), 0, 0);
273
- this.addChild(this.#currentWidthPreviewText);
274
- this.addChild(new Text(theme.fg("muted", "Narrow width preview:"), 0, 0));
275
- this.#narrowWidthPreviewText = new Text(this.#statusLinePreview(40), 0, 0);
276
- this.addChild(this.#narrowWidthPreviewText);
277
- this.addChild(new Spacer(1));
278
296
  this.#list = new SettingsList(
279
297
  this.#items(),
280
298
  14,
@@ -282,27 +300,17 @@ class StatusLineCustomEditor extends Container {
282
300
  (id, value) => this.#handleChange(id, value),
283
301
  () => this.#cancel(),
284
302
  item => this.#setSelectedItem(item),
303
+ 2,
285
304
  );
286
305
  this.addChild(this.#list);
287
306
  }
288
307
 
289
308
  #refresh(): void {
290
- this.#refreshPreview();
291
309
  this.#list.setItems(this.#items());
292
310
  }
293
-
294
- #refreshPreview(): void {
295
- this.#currentWidthPreviewText.setText(this.#statusLinePreview());
296
- this.#narrowWidthPreviewText.setText(this.#statusLinePreview(40));
297
- }
298
-
299
- #statusLinePreview(width?: number): string {
300
- return this.callbacks.getStatusLinePreview?.(width) ?? theme.fg("dim", "(preview not available)");
301
- }
302
311
  #setSelectedItem(item: SettingItem | undefined): void {
303
312
  this.#previewHighlightSegment = this.#highlightSegmentForItem(item);
304
313
  this.#preview();
305
- this.#refreshPreview();
306
314
  }
307
315
 
308
316
  #highlightSegmentForItem(item: SettingItem | undefined): StatusLineSegmentId | undefined {
@@ -388,6 +396,22 @@ class StatusLineCustomEditor extends Container {
388
396
  },
389
397
  );
390
398
  }
399
+ if (id === "usage") {
400
+ items.push({
401
+ id: "option.usage.mode",
402
+ label: "Usage: mode",
403
+ currentValue: this.#draft.segmentOptions.usage?.mode ?? "used",
404
+ submenu: (currentValue, done) =>
405
+ new SelectSubmenu(
406
+ "Usage mode",
407
+ "Show used quota or remaining quota in the usage segment.",
408
+ USAGE_MODE_OPTIONS,
409
+ currentValue,
410
+ done,
411
+ () => done(),
412
+ ),
413
+ });
414
+ }
391
415
  }
392
416
 
393
417
  items.push(
@@ -552,6 +576,12 @@ class StatusLineCustomEditor extends Container {
552
576
  case "time.showSeconds":
553
577
  this.#draft.segmentOptions.time = { ...(this.#draft.segmentOptions.time ?? {}), showSeconds: bool };
554
578
  break;
579
+ case "usage.mode":
580
+ this.#draft.segmentOptions.usage = {
581
+ ...(this.#draft.segmentOptions.usage ?? {}),
582
+ mode: value === "remaining" ? "remaining" : "used",
583
+ };
584
+ break;
555
585
  }
556
586
  }
557
587
 
@@ -836,9 +866,7 @@ export class SettingsSelectorComponent extends Container {
836
866
  };
837
867
  } else if (def.path === "statusLine.preset") {
838
868
  onPreview = value => {
839
- const presetDef = getPreset(
840
- value as "default" | "minimal" | "compact" | "full" | "nerd" | "ascii" | "custom",
841
- );
869
+ const presetDef = getPreset(value as StatusLinePreset);
842
870
  this.callbacks.onStatusLinePreview?.({
843
871
  preset: value as StatusLinePreset,
844
872
  leftSegments: presetDef.leftSegments,
@@ -976,6 +1004,16 @@ export class SettingsSelectorComponent extends Container {
976
1004
  10,
977
1005
  getSettingsListTheme(),
978
1006
  (id, newValue) => {
1007
+ if (id === STATUS_LINE_USAGE_MODE_ID) {
1008
+ const segmentOptions = setSavedUsageMode(newValue);
1009
+ this.callbacks.onChange("statusLine.segmentOptions", segmentOptions);
1010
+ if (tabId === "appearance") {
1011
+ this.#triggerStatusLinePreview();
1012
+ }
1013
+ this.#refreshCurrentTabItems(defs);
1014
+ return;
1015
+ }
1016
+
979
1017
  const def = defs.find(d => d.path === id);
980
1018
  if (!def) return;
981
1019
 
@@ -1019,13 +1057,20 @@ export class SettingsSelectorComponent extends Container {
1019
1057
  #buildItemsForTab(defs: SettingDef[], tabId: SettingTab): SettingItem[] {
1020
1058
  const items = this.#buildItemsForDefs(defs);
1021
1059
  if (tabId === "appearance") {
1060
+ const customEditorCallbacks: SettingsCallbacks = {
1061
+ ...this.callbacks,
1062
+ onStatusLinePreview: previewSettings => {
1063
+ this.callbacks.onStatusLinePreview?.(previewSettings);
1064
+ this.#updateStatusPreview();
1065
+ },
1066
+ };
1022
1067
  const customEditorItem: SettingItem = {
1023
1068
  id: STATUS_LINE_CUSTOM_EDITOR_ID,
1024
1069
  label: "Status Line Custom Editor",
1025
1070
  description:
1026
1071
  "Edit custom status line segments, placement, separator, and typed segment options with live previews.",
1027
1072
  currentValue: "open",
1028
- submenu: (_currentValue, done) => new StatusLineCustomEditor(this.callbacks, done),
1073
+ submenu: (_currentValue, done) => new StatusLineCustomEditor(customEditorCallbacks, done),
1029
1074
  };
1030
1075
  const presetIndex = items.findIndex(item => item.id === "statusLine.preset");
1031
1076
  if (presetIndex >= 0) {
@@ -1033,6 +1078,20 @@ export class SettingsSelectorComponent extends Container {
1033
1078
  } else {
1034
1079
  items.push(customEditorItem);
1035
1080
  }
1081
+ {
1082
+ const usageModeItem: SettingItem = {
1083
+ id: STATUS_LINE_USAGE_MODE_ID,
1084
+ label: "Status Line Usage Mode",
1085
+ description: "Show provider quota in the status line as used or remaining.",
1086
+ currentValue: getSavedUsageMode(),
1087
+ values: [...USAGE_MODE_VALUES],
1088
+ };
1089
+ if (presetIndex >= 0) {
1090
+ items.splice(presetIndex + 2, 0, usageModeItem);
1091
+ } else {
1092
+ items.push(usageModeItem);
1093
+ }
1094
+ }
1036
1095
  }
1037
1096
  return items;
1038
1097
  }
@@ -12,6 +12,17 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
12
12
  },
13
13
  },
14
14
 
15
+ "default-usage": {
16
+ leftSegments: ["model", "mode", "git", "pr", "path"],
17
+ rightSegments: ["session_name", "jobs", "token_rate", "usage", "context_pct", "cost"],
18
+ separator: "slash",
19
+ segmentOptions: {
20
+ model: { showThinkingLevel: true },
21
+ path: { abbreviate: true, maxLength: 32, stripWorkPrefix: true },
22
+ git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
23
+ },
24
+ },
25
+
15
26
  minimal: {
16
27
  leftSegments: ["path", "git"],
17
28
  rightSegments: ["session_name", "jobs", "mode", "context_pct"],
@@ -480,7 +480,12 @@ const sessionNameSegment: StatusLineSegment = {
480
480
  },
481
481
  };
482
482
 
483
- function pickUsageColor(percent: number): "muted" | "warning" | "error" {
483
+ function pickUsageColor(percent: number, mode: "used" | "remaining"): "muted" | "warning" | "error" {
484
+ if (mode === "remaining") {
485
+ if (percent <= 20) return "error";
486
+ if (percent <= 50) return "warning";
487
+ return "muted";
488
+ }
484
489
  if (percent >= 80) return "error";
485
490
  if (percent >= 50) return "warning";
486
491
  return "muted";
@@ -508,8 +513,11 @@ const usageSegment: StatusLineSegment = {
508
513
  if (!u || u.windows.length === 0) {
509
514
  return { content: "", visible: false };
510
515
  }
516
+ const mode = ctx.options.usage?.mode === "remaining" ? "remaining" : "used";
511
517
  const parts = u.windows.map(window => {
512
- const pctText = theme.fg(pickUsageColor(window.percent), `${Math.round(window.percent)}%`);
518
+ const displayPercent =
519
+ mode === "remaining" ? Math.max(0, Math.min(100, 100 - window.percent)) : window.percent;
520
+ const pctText = theme.fg(pickUsageColor(displayPercent, mode), `${Math.round(displayPercent)}%`);
513
521
  const reset =
514
522
  window.resetValue !== undefined && window.resetUnit !== undefined
515
523
  ? theme.fg("muted", ` (${formatUsageReset(window.resetValue, window.resetUnit)})`)
@@ -32,6 +32,7 @@ export interface StatusLineSegmentOptions {
32
32
  path?: { abbreviate?: boolean; maxLength?: number; stripWorkPrefix?: boolean };
33
33
  git?: { showBranch?: boolean; showStaged?: boolean; showUnstaged?: boolean; showUntracked?: boolean };
34
34
  time?: { format?: "12h" | "24h"; showSeconds?: boolean };
35
+ usage?: { mode?: "used" | "remaining" };
35
36
  }
36
37
 
37
38
  export interface StatusLineSettings {
@@ -617,7 +617,8 @@ export class ExtensionUiController {
617
617
  const listChromeRows = dialogOptions?.outline === true ? HOOK_SELECTOR_OUTLINE_ROWS : 0;
618
618
  // Reserve rows for the inline custom-input editor so opening it doesn't
619
619
  // push the scrollable title past the viewport into terminal scrollback.
620
- const inlineInputRows = dialogOptions?.customInput ? HOOK_SELECTOR_INLINE_INPUT_ROWS : 0;
620
+ const inlineInputRows =
621
+ dialogOptions?.customInput || dialogOptions?.clarificationInput ? HOOK_SELECTOR_INLINE_INPUT_ROWS : 0;
621
622
  const availableTitleRows =
622
623
  this.ctx.ui.terminal.rows - scrollOptionRows - listChromeRows - inlineInputRows - HOOK_SELECTOR_CHROME_ROWS;
623
624
  const scrollTitleRows =
@@ -659,7 +660,10 @@ export class ExtensionUiController {
659
660
  tui: this.ctx.ui,
660
661
  // Share the main prompt editor's autocomplete provider so the
661
662
  // inline "Other (type your own)" editor supports `@` file links.
662
- autocompleteProvider: dialogOptions?.customInput ? this.ctx.editor.getAutocompleteProvider() : undefined,
663
+ autocompleteProvider:
664
+ dialogOptions?.customInput || dialogOptions?.clarificationInput
665
+ ? this.ctx.editor.getAutocompleteProvider()
666
+ : undefined,
663
667
  outline: dialogOptions?.outline,
664
668
  wrapFocused: dialogOptions?.wrapFocused,
665
669
  scrollTitleRows,
@@ -675,6 +679,18 @@ export class ExtensionUiController {
675
679
  },
676
680
  }
677
681
  : undefined,
682
+ clarificationInput: dialogOptions?.clarificationInput
683
+ ? {
684
+ optionLabel: dialogOptions.clarificationInput.optionLabel,
685
+ allowEmpty: dialogOptions.clarificationInput.allowEmpty,
686
+ onSubmit: text => {
687
+ const optionLabel = dialogOptions.clarificationInput?.optionLabel;
688
+ this.hideHookSelector();
689
+ dialogOptions.clarificationInput?.onSubmit(text);
690
+ finish(optionLabel);
691
+ },
692
+ }
693
+ : undefined,
678
694
  },
679
695
  );
680
696
  // Detach (not dispose) the reusable editor before mounting the transient hook UI, so the
@@ -715,6 +731,7 @@ export class ExtensionUiController {
715
731
  title: string,
716
732
  placeholder?: string,
717
733
  dialogOptions?: ExtensionUIDialogOptions,
734
+ inputOptions?: { readonly initialValue?: string },
718
735
  ): Promise<string | undefined> {
719
736
  const { promise, finish, attachAbort } = this.#createHookDialogState(
720
737
  () => this.hideHookInput(),
@@ -732,6 +749,7 @@ export class ExtensionUiController {
732
749
  finish(undefined);
733
750
  },
734
751
  {
752
+ initialValue: inputOptions?.initialValue,
735
753
  timeout: dialogOptions?.timeout,
736
754
  onTimeout: dialogOptions?.onTimeout,
737
755
  tui: this.ctx.ui,