@gajae-code/coding-agent 0.7.7 → 0.7.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 (63) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +8 -0
  3. package/dist/types/cli/local-provider-smoke.d.ts +59 -0
  4. package/dist/types/cli/notify-cli.d.ts +1 -1
  5. package/dist/types/cli/update-cli.d.ts +24 -1
  6. package/dist/types/commands/local-provider.d.ts +34 -0
  7. package/dist/types/config/model-registry.d.ts +5 -0
  8. package/dist/types/config/models-config-schema.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +45 -0
  10. package/dist/types/config/settings.d.ts +7 -0
  11. package/dist/types/coordinator-mcp/server.d.ts +8 -0
  12. package/dist/types/gjc-runtime/launch-tmux.d.ts +2 -2
  13. package/dist/types/gjc-runtime/team-runtime.d.ts +1 -1
  14. package/dist/types/gjc-runtime/tmux-common.d.ts +12 -0
  15. package/dist/types/modes/components/custom-model-preset-wizard.d.ts +1 -1
  16. package/dist/types/modes/components/model-selector.d.ts +2 -0
  17. package/dist/types/modes/components/settings-selector.d.ts +4 -1
  18. package/dist/types/modes/components/status-line/types.d.ts +5 -7
  19. package/dist/types/modes/components/status-line.d.ts +4 -3
  20. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  21. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  22. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
  23. package/dist/types/modes/tmux-scroll.d.ts +18 -0
  24. package/dist/types/modes/utils/terminal-bell.d.ts +3 -0
  25. package/dist/types/notifications/telegram-daemon-cli.d.ts +13 -2
  26. package/package.json +7 -7
  27. package/src/cli/local-provider-smoke.ts +632 -0
  28. package/src/cli/notify-cli.ts +4 -2
  29. package/src/cli/update-cli.ts +155 -13
  30. package/src/cli.ts +18 -0
  31. package/src/commands/launch.ts +46 -1
  32. package/src/commands/local-provider.ts +62 -0
  33. package/src/commands/notify.ts +1 -1
  34. package/src/config/model-registry.ts +55 -1
  35. package/src/config/models-config-schema.ts +9 -0
  36. package/src/config/settings-schema.ts +47 -0
  37. package/src/config/settings.ts +11 -0
  38. package/src/coordinator-mcp/server.ts +276 -34
  39. package/src/gjc-runtime/launch-tmux.ts +34 -8
  40. package/src/gjc-runtime/launch-worktree.ts +16 -2
  41. package/src/gjc-runtime/team-runtime.ts +21 -6
  42. package/src/gjc-runtime/tmux-common.ts +16 -0
  43. package/src/gjc-runtime/tmux-sessions.ts +13 -9
  44. package/src/internal-urls/docs-index.generated.ts +5 -4
  45. package/src/modes/components/agent-dashboard.ts +2 -1
  46. package/src/modes/components/custom-model-preset-wizard.ts +30 -189
  47. package/src/modes/components/model-selector.ts +182 -5
  48. package/src/modes/components/session-observer-overlay.ts +3 -3
  49. package/src/modes/components/settings-selector.ts +491 -5
  50. package/src/modes/components/status-line/segments.ts +7 -18
  51. package/src/modes/components/status-line/types.ts +6 -2
  52. package/src/modes/components/status-line.ts +69 -35
  53. package/src/modes/controllers/event-controller.ts +98 -4
  54. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  55. package/src/modes/controllers/input-controller.ts +15 -0
  56. package/src/modes/controllers/selector-controller.ts +12 -4
  57. package/src/modes/prompt-action-autocomplete.ts +8 -0
  58. package/src/modes/tmux-scroll.ts +92 -0
  59. package/src/modes/utils/terminal-bell.ts +48 -0
  60. package/src/notifications/telegram-daemon-cli.ts +112 -9
  61. package/src/notifications/telegram-daemon.ts +4 -0
  62. package/src/session/agent-session.ts +74 -5
  63. package/src/slash-commands/builtin-registry.ts +106 -13
@@ -40,6 +40,7 @@ export interface StatusLineSettings {
40
40
  rightSegments?: StatusLineSegmentId[];
41
41
  separator?: StatusLineSeparatorStyle;
42
42
  segmentOptions?: StatusLineSegmentOptions;
43
+ previewHighlightSegment?: StatusLineSegmentId;
43
44
  showHookStatus?: boolean;
44
45
  showSkillHud?: boolean;
45
46
  sessionAccent?: boolean;
@@ -181,11 +182,8 @@ export class StatusLineComponent implements Component {
181
182
  #lastTokensPerSecond: number | null = null;
182
183
  #lastTokensPerSecondTimestamp: number | null = null;
183
184
 
184
- // Anthropic usage caching (5-min TTL, OAuth/sub only)
185
- #cachedUsage: {
186
- fiveHour?: { percent: number; resetMinutes?: number };
187
- sevenDay?: { percent: number; resetHours?: number };
188
- } | null = null;
185
+ // Provider usage caching (5-min TTL, OAuth/sub only)
186
+ #cachedUsage: SegmentContext["usage"] = null;
189
187
  #usageFetchedAt = 0;
190
188
  #usageInFlight = false;
191
189
  // Context breakdown — incremental cache. Replaces the previous 2-second
@@ -221,7 +219,7 @@ export class StatusLineComponent implements Component {
221
219
  }
222
220
 
223
221
  updateSettings(settings: StatusLineSettings): void {
224
- this.#settings = { ...this.#settings, ...settings };
222
+ this.#settings = { ...this.#settings, previewHighlightSegment: undefined, ...settings };
225
223
  }
226
224
 
227
225
  setAutoCompactEnabled(enabled: boolean): void {
@@ -462,9 +460,9 @@ export class StatusLineComponent implements Component {
462
460
  }
463
461
 
464
462
  /**
465
- * Background-refresh the Anthropic OAuth quota report. Guarded by a 5-min
466
- * TTL on both success (cache lifetime) and error (backoff). Exposed
467
- * (non-private) so unit tests can verify the backoff invariant.
463
+ * Background-refresh the OAuth quota report. Guarded by a 5-min TTL on both
464
+ * success (cache lifetime) and error (backoff). Exposed (non-private) so
465
+ * unit tests can verify the backoff invariant.
468
466
  */
469
467
  refreshUsageInBackground(): void {
470
468
  const now = Date.now();
@@ -478,6 +476,9 @@ export class StatusLineComponent implements Component {
478
476
  .then(reports => {
479
477
  this.#cachedUsage = this.#normalizeUsageReports(reports);
480
478
  this.#usageFetchedAt = Date.now();
479
+ if (this.#onBranchChange) {
480
+ this.#onBranchChange();
481
+ }
481
482
  })
482
483
  .catch(() => {
483
484
  // Backoff on error: stamp the fetch time so the 5-min TTL guard
@@ -491,47 +492,77 @@ export class StatusLineComponent implements Component {
491
492
  });
492
493
  }
493
494
 
494
- #normalizeUsageReports(reports: unknown): {
495
- fiveHour?: { percent: number; resetMinutes?: number };
496
- sevenDay?: { percent: number; resetHours?: number };
497
- } | null {
495
+ #normalizeUsageReports(reports: unknown): SegmentContext["usage"] {
498
496
  if (!Array.isArray(reports)) return null;
499
- let fiveHour: { percent: number; resetMinutes?: number } | undefined;
500
- let sevenDay: { percent: number; resetHours?: number } | undefined;
497
+ const windows: NonNullable<SegmentContext["usage"]>["windows"] = [];
498
+ const seen = new Set<string>();
501
499
  const now = Date.now();
500
+
501
+ const codexWindowLabel = (windowId: string | undefined, fallback: string): string => {
502
+ if (windowId && /^\d+[hd]$/.test(windowId)) return windowId;
503
+ return fallback;
504
+ };
505
+ const codexResetUnit = (label: string): "m" | "h" => (label.endsWith("d") ? "h" : "m");
506
+
507
+ const pushWindow = (
508
+ key: string,
509
+ label: string,
510
+ fraction: number,
511
+ resetsAt: number | undefined,
512
+ resetUnit: "m" | "h",
513
+ ) => {
514
+ if (seen.has(key)) return;
515
+ seen.add(key);
516
+ windows.push({
517
+ label,
518
+ percent: fraction * 100,
519
+ resetValue:
520
+ typeof resetsAt === "number"
521
+ ? Math.max(0, Math.round((resetsAt - now) / (resetUnit === "m" ? 60_000 : 3_600_000)))
522
+ : undefined,
523
+ resetUnit,
524
+ });
525
+ };
526
+
502
527
  for (const report of reports) {
503
528
  if (!report || typeof report !== "object") continue;
529
+ const provider = (report as { provider?: unknown }).provider;
530
+ const providerId = typeof provider === "string" ? provider : undefined;
504
531
  const limits = (report as { limits?: unknown }).limits;
505
532
  if (!Array.isArray(limits)) continue;
506
533
  for (const limit of limits) {
507
534
  if (!limit || typeof limit !== "object") continue;
508
535
  const l = limit as {
509
- scope?: { windowId?: string; tier?: string };
510
- window?: { resetsAt?: number };
536
+ id?: unknown;
537
+ scope?: { windowId?: string; tier?: string; modelId?: string };
538
+ window?: { id?: string; resetsAt?: number };
511
539
  amount?: { usedFraction?: number };
512
540
  };
513
541
  const fraction = l.amount?.usedFraction;
514
542
  if (typeof fraction !== "number") continue;
515
- const windowId = l.scope?.windowId;
543
+ const id = typeof l.id === "string" ? l.id : "";
544
+ const windowId = l.scope?.windowId ?? l.window?.id;
516
545
  const tier = l.scope?.tier;
546
+ const modelId = l.scope?.modelId;
517
547
  const resetsAt = l.window?.resetsAt;
518
- if (windowId === "5h" && !tier && !fiveHour) {
519
- fiveHour = {
520
- percent: fraction * 100,
521
- resetMinutes:
522
- typeof resetsAt === "number" ? Math.max(0, Math.round((resetsAt - now) / 60_000)) : undefined,
523
- };
524
- } else if (windowId === "7d" && !tier && !sevenDay) {
525
- sevenDay = {
526
- percent: fraction * 100,
527
- resetHours:
528
- typeof resetsAt === "number" ? Math.max(0, Math.round((resetsAt - now) / 3_600_000)) : undefined,
529
- };
548
+
549
+ if (providerId === "openai-codex") {
550
+ if (id === "openai-codex:primary" || (!id && !!windowId && windowId !== "7d" && !modelId)) {
551
+ const label = codexWindowLabel(windowId, "primary");
552
+ pushWindow("codex:primary", label, fraction, resetsAt, codexResetUnit(label));
553
+ } else if (id === "openai-codex:secondary" || (!id && windowId === "7d" && !modelId)) {
554
+ const label = codexWindowLabel(windowId, "secondary");
555
+ pushWindow("codex:secondary", label, fraction, resetsAt, codexResetUnit(label));
556
+ }
557
+ } else if (windowId === "5h" && !tier) {
558
+ pushWindow(`${providerId ?? "provider"}:5h`, "5h", fraction, resetsAt, "m");
559
+ } else if (windowId === "7d" && !tier) {
560
+ pushWindow(`${providerId ?? "provider"}:7d`, "7d", fraction, resetsAt, "h");
530
561
  }
531
562
  }
532
563
  }
533
- if (!fiveHour && !sevenDay) return null;
534
- return { fiveHour, sevenDay };
564
+
565
+ return windows.length > 0 ? { windows } : null;
535
566
  }
536
567
 
537
568
  /**
@@ -684,12 +715,15 @@ export class StatusLineComponent implements Component {
684
715
  const sepAnsi = theme.getFgAnsi("statusLineSep");
685
716
 
686
717
  // Collect visible segment contents
718
+ const highlightSegment = (segId: StatusLineSegmentId, content: string): string =>
719
+ effectiveSettings.previewHighlightSegment === segId ? `\x1b[7m${content}\x1b[27m` : content;
720
+
687
721
  const leftParts: string[] = [];
688
722
  const leftSegIds: StatusLineSegmentId[] = [];
689
723
  for (const segId of effectiveSettings.leftSegments) {
690
724
  const rendered = renderSegment(segId, ctx);
691
725
  if (rendered.visible && rendered.content) {
692
- leftParts.push(rendered.content);
726
+ leftParts.push(highlightSegment(segId, rendered.content));
693
727
  leftSegIds.push(segId);
694
728
  }
695
729
  }
@@ -698,7 +732,7 @@ export class StatusLineComponent implements Component {
698
732
  for (const segId of effectiveSettings.rightSegments) {
699
733
  const rendered = renderSegment(segId, ctx);
700
734
  if (rendered.visible && rendered.content) {
701
- rightParts.push(rendered.content);
735
+ rightParts.push(highlightSegment(segId, rendered.content));
702
736
  }
703
737
  }
704
738
 
@@ -765,7 +799,7 @@ export class StatusLineComponent implements Component {
765
799
  if (!adjusted.visible || !adjusted.content) break;
766
800
  reRendered = adjusted;
767
801
  }
768
- left[pathIdx] = reRendered.content;
802
+ left[pathIdx] = highlightSegment("path", reRendered.content);
769
803
  leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
770
804
  }
771
805
  }
@@ -3,6 +3,7 @@ import { calculatePromptTokens } from "@gajae-code/agent-core/compaction/compact
3
3
  import type { AssistantMessage, ImageContent } from "@gajae-code/ai";
4
4
  import { parseRateLimitReason } from "@gajae-code/ai";
5
5
  import { type Component, Loader, TERMINAL, Text } from "@gajae-code/tui";
6
+ import { logger } from "@gajae-code/utils";
6
7
  import { settings } from "../../config/settings";
7
8
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
8
9
  import {
@@ -15,16 +16,56 @@ import { ToolExecutionComponent } from "../../modes/components/tool-execution";
15
16
  import { TtsrNotificationComponent } from "../../modes/components/ttsr-notification";
16
17
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
17
18
  import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
19
+ import { summaryFromMessage } from "../../notifications/helpers";
18
20
  import type { PlanApprovalDetails } from "../../plan-mode/approved-plan";
19
21
  import type { AgentSessionEvent } from "../../session/agent-session";
20
22
  import { isSilentAbort, readPendingDisplayTag } from "../../session/messages";
21
23
  import type { ResolveToolDetails } from "../../tools/resolve";
22
24
  import { interruptHint } from "../shared";
23
25
  import { buildAbortDisplayMessage } from "../utils/abort-message";
26
+ import { ringTerminalBell } from "../utils/terminal-bell";
24
27
 
25
28
  type AgentSessionEventKind = AgentSessionEvent["type"];
26
29
 
27
30
  const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000;
31
+ const COMPLETION_NOTIFY_COMMAND_TIMEOUT_MS = 10_000;
32
+
33
+ interface CompletionNotifyPayload {
34
+ type: "agent-turn-complete";
35
+ title: string;
36
+ body: string;
37
+ cwd: string;
38
+ sessionId?: string;
39
+ sessionName?: string;
40
+ lastAssistantMessage?: string;
41
+ stopReason?: string;
42
+ }
43
+
44
+ function completionNotifyShellCommand(command: string): string[] {
45
+ if (process.platform === "win32") {
46
+ return ["cmd.exe", "/d", "/s", "/c", command];
47
+ }
48
+ return ["/bin/sh", "-c", command];
49
+ }
50
+
51
+ function cleanNotificationEnvValue(value: string | undefined, max = 4000): string {
52
+ if (!value) return "";
53
+ return value.replaceAll("\0", "").slice(0, max);
54
+ }
55
+
56
+ function buildCompletionNotifyEnv(payload: CompletionNotifyPayload): Record<string, string> {
57
+ return {
58
+ GJC_NOTIFICATION_TYPE: payload.type,
59
+ GJC_NOTIFICATION_TITLE: cleanNotificationEnvValue(payload.title, 500),
60
+ GJC_NOTIFICATION_BODY: cleanNotificationEnvValue(payload.body),
61
+ GJC_NOTIFICATION_CWD: cleanNotificationEnvValue(payload.cwd, 2000),
62
+ GJC_NOTIFICATION_SESSION_ID: cleanNotificationEnvValue(payload.sessionId, 500),
63
+ GJC_NOTIFICATION_SESSION_NAME: cleanNotificationEnvValue(payload.sessionName, 500),
64
+ GJC_NOTIFICATION_LAST_ASSISTANT_MESSAGE: cleanNotificationEnvValue(payload.lastAssistantMessage),
65
+ GJC_NOTIFICATION_STOP_REASON: cleanNotificationEnvValue(payload.stopReason, 100),
66
+ GJC_NOTIFICATION_JSON: cleanNotificationEnvValue(JSON.stringify(payload), 8000),
67
+ };
68
+ }
28
69
 
29
70
  function friendlyRetryReason(errorMessage: string | undefined): string {
30
71
  if (!errorMessage) return "";
@@ -857,8 +898,46 @@ export class EventController {
857
898
  return lastAssistant?.usage ? calculatePromptTokens(lastAssistant.usage) : 0;
858
899
  }
859
900
 
901
+ #runCompletionNotifyCommand(payload: CompletionNotifyPayload): void {
902
+ const command = settings.getGlobal("completion.notifyCommand")?.trim();
903
+ if (!command) return;
904
+
905
+ try {
906
+ const proc = Bun.spawn(completionNotifyShellCommand(command), {
907
+ cwd: payload.cwd,
908
+ env: {
909
+ ...process.env,
910
+ ...buildCompletionNotifyEnv(payload),
911
+ },
912
+ stdin: "ignore",
913
+ stdout: "ignore",
914
+ stderr: "ignore",
915
+ });
916
+ proc.unref();
917
+ const timer = setTimeout(() => {
918
+ try {
919
+ proc.kill();
920
+ } catch {}
921
+ }, COMPLETION_NOTIFY_COMMAND_TIMEOUT_MS);
922
+ timer.unref?.();
923
+ void proc.exited
924
+ .then(exitCode => {
925
+ clearTimeout(timer);
926
+ if (exitCode !== 0) {
927
+ logger.warn("completion notify command exited non-zero", { exitCode });
928
+ }
929
+ })
930
+ .catch(error => {
931
+ clearTimeout(timer);
932
+ logger.warn("completion notify command failed", { error: String(error) });
933
+ });
934
+ } catch (error) {
935
+ logger.warn("completion notify command failed to start", { error: String(error) });
936
+ }
937
+ }
938
+
860
939
  sendCompletionNotification(): void {
861
- if (this.ctx.isBackgrounded === false) return;
940
+ const isBackgrounded = this.ctx.isBackgrounded !== false;
862
941
  const notify = settings.get("completion.notify");
863
942
  if (notify === "off") return;
864
943
 
@@ -869,9 +948,24 @@ export class EventController {
869
948
  const last = this.ctx.session.getLastAssistantMessage?.();
870
949
  if (last?.stopReason === "aborted" || last?.stopReason === "error") return;
871
950
 
872
- const title = this.ctx.sessionManager.getSessionName();
873
- const message = title ? `${title}: Complete` : "Complete";
874
- TERMINAL.sendNotification(message);
951
+ const sessionName = this.ctx.sessionManager.getSessionName();
952
+ const title = sessionName ? `${sessionName}: Complete` : "Complete";
953
+ const summary = summaryFromMessage(last, 1000);
954
+ const body = summary ?? "Complete";
955
+ const sessionManager = this.ctx.sessionManager as { getCwd?: () => string; getSessionId?: () => string };
956
+ const payload: CompletionNotifyPayload = {
957
+ type: "agent-turn-complete",
958
+ title,
959
+ body,
960
+ cwd: sessionManager.getCwd?.() ?? settings.getCwd(),
961
+ sessionId: sessionManager.getSessionId?.(),
962
+ sessionName: sessionName || undefined,
963
+ lastAssistantMessage: summary,
964
+ stopReason: last?.stopReason,
965
+ };
966
+ ringTerminalBell("complete");
967
+ if (isBackgrounded) TERMINAL.sendNotification(title);
968
+ this.#runCompletionNotifyCommand(payload);
875
969
  }
876
970
 
877
971
  async handleBackgroundEvent(event: AgentSessionEvent): Promise<void> {
@@ -23,6 +23,7 @@ import { HookSelectorComponent } from "../../modes/components/hook-selector";
23
23
  import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, theme } from "../../modes/theme/theme";
24
24
  import type { InteractiveModeContext } from "../../modes/types";
25
25
  import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
26
+ import { classifyHookSelectorBellEvent, ringTerminalBell } from "../utils/terminal-bell";
26
27
 
27
28
  const MAX_WIDGET_LINES = 10;
28
29
  const HOOK_SELECTOR_CHROME_ROWS = 7;
@@ -622,6 +623,8 @@ export class ExtensionUiController {
622
623
  const scrollTitleRows =
623
624
  requestedTitleRows === undefined ? undefined : Math.max(1, Math.min(requestedTitleRows, availableTitleRows));
624
625
 
626
+ ringTerminalBell(classifyHookSelectorBellEvent(title));
627
+
625
628
  this.ctx.hookSelector = new HookSelectorComponent(
626
629
  title,
627
630
  options,
@@ -9,6 +9,7 @@ import { buildSkillPromptMessage, parseSkillInvocations } from "../../extensibil
9
9
  import { expandEmoticons } from "../../modes/emoji-autocomplete";
10
10
  import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
11
11
  import { theme } from "../../modes/theme/theme";
12
+ import { scrollTmuxToPreviousUserInput as scrollTmuxPaneToPreviousUserInput } from "../../modes/tmux-scroll";
12
13
  import type { InteractiveModeContext } from "../../modes/types";
13
14
  import type { AgentSessionEvent } from "../../session/agent-session";
14
15
  import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages";
@@ -858,6 +859,7 @@ export class InputController {
858
859
  copyCurrentLine: () => this.handleCopyCurrentLine(),
859
860
  copyPrompt: () => this.handleCopyPrompt(),
860
861
  pasteImage: () => void this.handleImagePaste(),
862
+ scrollTmuxToPreviousUserInput: () => this.scrollTmuxToPreviousUserInput(),
861
863
  undo: prefix => this.ctx.editor.undoPastTransientText(prefix),
862
864
  moveCursorToMessageEnd: () => this.ctx.editor.moveToMessageEnd(),
863
865
  moveCursorToMessageStart: () => this.ctx.editor.moveToMessageStart(),
@@ -952,6 +954,19 @@ export class InputController {
952
954
  this.setToolsExpanded(!this.ctx.toolOutputExpanded);
953
955
  }
954
956
 
957
+ scrollTmuxToPreviousUserInput(): void {
958
+ const result = scrollTmuxPaneToPreviousUserInput();
959
+ if (result.ok) return;
960
+
961
+ if (result.reason === "not_inside_tmux") {
962
+ this.ctx.showWarning("Previous-input scroll works only inside tmux.");
963
+ return;
964
+ }
965
+
966
+ const detail = result.error ? `: ${result.error}` : ".";
967
+ this.ctx.showWarning(`Failed to scroll tmux to previous user input${detail}`);
968
+ }
969
+
955
970
  setToolsExpanded(expanded: boolean): void {
956
971
  this.ctx.toolOutputExpanded = expanded;
957
972
  for (const child of this.ctx.chatContainer.children) {
@@ -8,6 +8,7 @@ import { activateModelProfile, materializeActiveModelProfileAssignment } from ".
8
8
  import { recommendModelProfileForProvider } from "../../config/model-profiles";
9
9
  import { GJC_MODEL_ASSIGNMENT_TARGETS } from "../../config/model-registry";
10
10
  import { formatModelSelectorValue } from "../../config/model-resolver";
11
+ import type { ModelProfileConfig } from "../../config/models-config-schema";
11
12
  import { settings } from "../../config/settings";
12
13
  import { DebugSelectorComponent } from "../../debug";
13
14
  import { disableProvider, enableProvider } from "../../discovery";
@@ -239,7 +240,7 @@ export class SelectorController {
239
240
  this.ctx.ui.requestRender();
240
241
  }
241
242
 
242
- showCustomModelPresetWizard(): void {
243
+ showCustomModelPresetWizard(snapshot: ModelProfileConfig): void {
243
244
  this.showSelector(done => {
244
245
  let wizard: CustomModelPresetWizardComponent;
245
246
  const submit = async (input: CustomModelPresetWizardSubmit): Promise<void> => {
@@ -256,6 +257,7 @@ export class SelectorController {
256
257
  }
257
258
  };
258
259
  wizard = new CustomModelPresetWizardComponent(
260
+ snapshot,
259
261
  input => {
260
262
  void submit(input);
261
263
  },
@@ -336,14 +338,16 @@ export class SelectorController {
336
338
  separator: settings.get("statusLine.separator"),
337
339
  showHookStatus: settings.get("statusLine.showHookStatus"),
338
340
  sessionAccent: settings.get("statusLine.sessionAccent"),
341
+ segmentOptions: settings.get("statusLine.segmentOptions"),
339
342
  ...previewSettings,
340
343
  });
341
344
  this.ctx.updateEditorTopBorder();
342
345
  this.ctx.ui.requestRender();
343
346
  },
344
- getStatusLinePreview: () => {
347
+ getStatusLinePreview: (width?: number) => {
345
348
  // Return the rendered status line for inline preview
346
- const availableWidth = this.ctx.editor.getTopBorderAvailableWidth(this.ctx.ui.terminal.columns);
349
+ const availableWidth =
350
+ width ?? this.ctx.editor.getTopBorderAvailableWidth(this.ctx.ui.terminal.columns);
347
351
  return this.ctx.statusLine.getTopBorder(availableWidth).content;
348
352
  },
349
353
  onPluginsChanged: () => {
@@ -359,6 +363,7 @@ export class SelectorController {
359
363
  separator: settings.get("statusLine.separator"),
360
364
  showHookStatus: settings.get("statusLine.showHookStatus"),
361
365
  sessionAccent: settings.get("statusLine.sessionAccent"),
366
+ segmentOptions: settings.get("statusLine.segmentOptions"),
362
367
  });
363
368
  this.ctx.updateEditorTopBorder();
364
369
  this.ctx.ui.requestRender();
@@ -603,6 +608,9 @@ export class SelectorController {
603
608
  case "statusLineShowHooks":
604
609
  case "statusLine.showHookStatus":
605
610
  case "statusLine.sessionAccent":
611
+ case "statusLine.leftSegments":
612
+ case "statusLine.rightSegments":
613
+ case "statusLine.segmentOptions":
606
614
  case "statusLineSegments":
607
615
  case "statusLineModelThinking":
608
616
  case "statusLinePathAbbreviate":
@@ -682,7 +690,7 @@ export class SelectorController {
682
690
  try {
683
691
  if (selection.kind === "createProfile") {
684
692
  done();
685
- this.showCustomModelPresetWizard();
693
+ this.showCustomModelPresetWizard(selection.profile);
686
694
  return;
687
695
  }
688
696
  if (selection.kind === "profile") {
@@ -29,6 +29,7 @@ interface PromptActionAutocompleteOptions {
29
29
  copyCurrentLine: () => void;
30
30
  copyPrompt: () => void;
31
31
  pasteImage: () => void;
32
+ scrollTmuxToPreviousUserInput: () => void;
32
33
  undo: (prefix: string) => void;
33
34
  moveCursorToMessageEnd: () => void;
34
35
  moveCursorToMessageStart: () => void;
@@ -378,6 +379,13 @@ export function createPromptActionAutocompleteProvider(
378
379
  keywords: ["paste", "image", "clipboard", "screenshot", "attach", "vision"],
379
380
  execute: options.pasteImage,
380
381
  },
382
+ {
383
+ id: "tmux-previous-user-input",
384
+ label: "Scroll to previous user input",
385
+ description: "tmux copy-mode",
386
+ keywords: ["scroll", "tmux", "previous", "user", "input", "prompt", "history"],
387
+ execute: options.scrollTmuxToPreviousUserInput,
388
+ },
381
389
  {
382
390
  id: "undo",
383
391
  label: "Undo",
@@ -0,0 +1,92 @@
1
+ import { resolveGjcTmuxCommand } from "../gjc-runtime/tmux-common";
2
+
3
+ // tmux copy-mode pads lines to the pane width, so allow trailing spaces before
4
+ // the line end while keeping the match scoped to the standalone `user` label.
5
+ export const TMUX_PREVIOUS_USER_INPUT_SEARCH_PATTERN = "^ *user *$";
6
+
7
+ type TmuxScrollFailureReason =
8
+ | "not_inside_tmux"
9
+ | "tmux_command_failed"
10
+ | "copy_mode_failed"
11
+ | "history_bottom_failed"
12
+ | "search_failed";
13
+
14
+ export type TmuxScrollToPreviousUserInputResult =
15
+ | { ok: true }
16
+ | { ok: false; reason: TmuxScrollFailureReason; error?: string };
17
+
18
+ interface TmuxSpawnResult {
19
+ exitCode: number | null;
20
+ stderr?: { toString(): string };
21
+ }
22
+
23
+ type TmuxSpawnSync = (command: string, args: string[], env: NodeJS.ProcessEnv) => TmuxSpawnResult;
24
+
25
+ function defaultSpawnSync(command: string, args: string[], env: NodeJS.ProcessEnv): TmuxSpawnResult {
26
+ return Bun.spawnSync([command, ...args], {
27
+ env,
28
+ stdout: "ignore",
29
+ stderr: "pipe",
30
+ });
31
+ }
32
+
33
+ function getErrorText(result: TmuxSpawnResult): string | undefined {
34
+ const text = result.stderr?.toString().trim();
35
+ return text ? text : undefined;
36
+ }
37
+
38
+ function runTmuxStep(
39
+ command: string,
40
+ args: string[],
41
+ env: NodeJS.ProcessEnv,
42
+ spawnSync: TmuxSpawnSync,
43
+ reason: TmuxScrollFailureReason,
44
+ ): TmuxScrollToPreviousUserInputResult {
45
+ try {
46
+ const result = spawnSync(command, args, env);
47
+ if (result.exitCode === 0) return { ok: true };
48
+ return { ok: false, reason, error: getErrorText(result) };
49
+ } catch (error) {
50
+ return { ok: false, reason, error: error instanceof Error ? error.message : String(error) };
51
+ }
52
+ }
53
+
54
+ export function scrollTmuxToPreviousUserInput(
55
+ env: NodeJS.ProcessEnv = process.env,
56
+ spawnSync: TmuxSpawnSync = defaultSpawnSync,
57
+ ): TmuxScrollToPreviousUserInputResult {
58
+ if (!env.TMUX) return { ok: false, reason: "not_inside_tmux" };
59
+
60
+ let tmuxCommand: string;
61
+ try {
62
+ tmuxCommand = resolveGjcTmuxCommand(env);
63
+ } catch (error) {
64
+ return {
65
+ ok: false,
66
+ reason: "tmux_command_failed",
67
+ error: error instanceof Error ? error.message : String(error),
68
+ };
69
+ }
70
+
71
+ const targetPane = env.TMUX_PANE?.trim();
72
+ const targetArgs = targetPane ? ["-t", targetPane] : [];
73
+ const copyModeResult = runTmuxStep(tmuxCommand, ["copy-mode", ...targetArgs], env, spawnSync, "copy_mode_failed");
74
+ if (!copyModeResult.ok) return copyModeResult;
75
+
76
+ const historyBottomResult = runTmuxStep(
77
+ tmuxCommand,
78
+ ["send-keys", ...targetArgs, "-X", "history-bottom"],
79
+ env,
80
+ spawnSync,
81
+ "history_bottom_failed",
82
+ );
83
+ if (!historyBottomResult.ok) return historyBottomResult;
84
+
85
+ return runTmuxStep(
86
+ tmuxCommand,
87
+ ["send-keys", ...targetArgs, "-X", "search-backward", TMUX_PREVIOUS_USER_INPUT_SEARCH_PATTERN],
88
+ env,
89
+ spawnSync,
90
+ "search_failed",
91
+ );
92
+ }
@@ -0,0 +1,48 @@
1
+ import { settings } from "../../config/settings";
2
+
3
+ export type TerminalBellEvent = "complete" | "approval" | "ask";
4
+
5
+ const BEL = "\x07";
6
+
7
+ function getBellSetting(key: Parameters<typeof settings.get>[0]): boolean {
8
+ try {
9
+ return Boolean(settings.get(key));
10
+ } catch {
11
+ // Some component-level tests exercise UI helpers before Settings.init().
12
+ // Terminal bells are purely best-effort, so an unavailable settings store
13
+ // must not make the UI path throw.
14
+ return false;
15
+ }
16
+ }
17
+
18
+ function enabledForEvent(event: TerminalBellEvent): boolean {
19
+ if (!getBellSetting("notifications.terminalBell")) return false;
20
+ switch (event) {
21
+ case "complete":
22
+ return getBellSetting("notifications.bellOnComplete");
23
+ case "approval":
24
+ return getBellSetting("notifications.bellOnApproval");
25
+ case "ask":
26
+ return getBellSetting("notifications.bellOnAsk");
27
+ }
28
+ }
29
+
30
+ export function ringTerminalBell(
31
+ event: TerminalBellEvent,
32
+ output: Pick<NodeJS.WriteStream, "write"> = process.stdout,
33
+ ): void {
34
+ if (!enabledForEvent(event)) return;
35
+ try {
36
+ output.write(BEL);
37
+ } catch {
38
+ // Best-effort local notification only.
39
+ }
40
+ }
41
+
42
+ export function classifyHookSelectorBellEvent(title: string): TerminalBellEvent {
43
+ const normalized = title.toLowerCase();
44
+ if (normalized.includes("approval") || normalized.includes("approve") || normalized.includes("plan ready")) {
45
+ return "approval";
46
+ }
47
+ return "ask";
48
+ }