@gajae-code/coding-agent 0.15.6 → 0.16.0

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 (167) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/types/cli/args.d.ts +2 -0
  3. package/dist/types/cli/root-flags.d.ts +14 -0
  4. package/dist/types/cli/update-cli.d.ts +13 -0
  5. package/dist/types/cli.d.ts +7 -0
  6. package/dist/types/commands/launch.d.ts +10 -0
  7. package/dist/types/config/autorouting-tier-map.d.ts +24 -0
  8. package/dist/types/config/file-lock.d.ts +5 -0
  9. package/dist/types/config/model-registry.d.ts +6 -0
  10. package/dist/types/config/settings-schema.d.ts +10 -1
  11. package/dist/types/config/settings.d.ts +2 -0
  12. package/dist/types/coordinator-mcp/projection-scan.d.ts +8 -0
  13. package/dist/types/crash/record-loader.d.ts +15 -0
  14. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  15. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  16. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +66 -1
  17. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  18. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  19. package/dist/types/master-mode/context.d.ts +21 -0
  20. package/dist/types/master-mode/first-request.d.ts +34 -0
  21. package/dist/types/master-mode/snapshot.d.ts +32 -0
  22. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  23. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  24. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  25. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  26. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  27. package/dist/types/modes/print-mode.d.ts +17 -0
  28. package/dist/types/modes/ui-language.d.ts +7 -1
  29. package/dist/types/sdk/broker/broker.d.ts +77 -1
  30. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  31. package/dist/types/sdk/broker/index.d.ts +3 -0
  32. package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
  33. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  34. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  35. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  36. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  37. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  38. package/dist/types/sdk/broker/state-version.d.ts +4 -1
  39. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  40. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  41. package/dist/types/sdk/bus/index.d.ts +6 -0
  42. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  43. package/dist/types/sdk/cli/index.d.ts +1 -0
  44. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  45. package/dist/types/sdk/cli/rows.d.ts +4 -3
  46. package/dist/types/sdk/cli/session-cli.d.ts +18 -2
  47. package/dist/types/sdk/client/client.d.ts +3 -2
  48. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  49. package/dist/types/sdk/host/control/index.d.ts +1 -1
  50. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  51. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  52. package/dist/types/sdk/host/host.d.ts +10 -0
  53. package/dist/types/sdk/host/session-runtime.d.ts +52 -0
  54. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  55. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  56. package/dist/types/sdk/lifecycle-session.d.ts +2 -0
  57. package/dist/types/sdk/router/session-router.d.ts +2 -0
  58. package/dist/types/sdk/session.d.ts +16 -0
  59. package/dist/types/sdk/startup-capability.d.ts +2 -1
  60. package/dist/types/session/agent-session.d.ts +5 -0
  61. package/dist/types/session/credential-store-errors.d.ts +1 -0
  62. package/dist/types/session/session-manager.d.ts +65 -0
  63. package/dist/types/session/yield-queue.d.ts +5 -4
  64. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  65. package/dist/types/tools/bash.d.ts +4 -0
  66. package/dist/types/tools/gh.d.ts +8 -0
  67. package/dist/types/tools/index.d.ts +4 -0
  68. package/package.json +7 -7
  69. package/scripts/generate-sdk-operation-inventory.ts +6 -0
  70. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  71. package/src/cli/accounts-cli.ts +9 -5
  72. package/src/cli/args.ts +17 -0
  73. package/src/cli/fast-help.ts +1 -0
  74. package/src/cli/root-flags.ts +5 -0
  75. package/src/cli/update-cli.ts +77 -11
  76. package/src/commands/launch.ts +86 -18
  77. package/src/commands/sdk.ts +113 -21
  78. package/src/config/autorouting-tier-map.ts +12 -0
  79. package/src/config/file-lock.ts +158 -83
  80. package/src/config/model-preset-registry.ts +1 -5
  81. package/src/config/model-profiles.ts +5 -5
  82. package/src/config/model-registry.ts +23 -0
  83. package/src/config/model-resolver.ts +5 -0
  84. package/src/config/settings-schema.ts +8 -1
  85. package/src/config/settings.ts +5 -0
  86. package/src/coordinator-mcp/projection-scan.ts +21 -0
  87. package/src/coordinator-mcp/question-state.ts +9 -11
  88. package/src/coordinator-mcp/server.ts +94 -20
  89. package/src/crash/index-store.ts +2 -0
  90. package/src/crash/record-loader.ts +10 -0
  91. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  92. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  93. package/src/gjc-runtime/session-state-lock.ts +79 -6
  94. package/src/gjc-runtime/session-state-sidecar.ts +929 -23
  95. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  96. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
  97. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  98. package/src/hooks/skill-state.ts +6 -0
  99. package/src/internal-urls/docs-index.generated.ts +7 -7
  100. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  101. package/src/main.ts +57 -2
  102. package/src/master-mode/context.ts +50 -0
  103. package/src/master-mode/first-request.ts +65 -0
  104. package/src/master-mode/snapshot.ts +205 -0
  105. package/src/modes/acp/acp-agent.ts +605 -156
  106. package/src/modes/components/footer.ts +4 -2
  107. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  108. package/src/modes/components/oauth-selector.ts +6 -1
  109. package/src/modes/components/settings-selector.ts +1 -0
  110. package/src/modes/components/status-line/model-name.ts +53 -0
  111. package/src/modes/components/status-line/priority-row.ts +107 -0
  112. package/src/modes/components/status-line/segments.ts +23 -9
  113. package/src/modes/components/tool-status-header.ts +151 -15
  114. package/src/modes/controllers/input-controller.ts +28 -0
  115. package/src/modes/controllers/selector-controller.ts +191 -96
  116. package/src/modes/print-mode.ts +64 -0
  117. package/src/modes/ui-language.ts +49 -2
  118. package/src/prompts/system/master-mode.md +28 -0
  119. package/src/sdk/broker/broker.ts +1636 -26
  120. package/src/sdk/broker/endpoint-authority.ts +86 -0
  121. package/src/sdk/broker/ensure.ts +5 -0
  122. package/src/sdk/broker/index.ts +3 -0
  123. package/src/sdk/broker/lifecycle.ts +186 -41
  124. package/src/sdk/broker/master-capability.ts +232 -0
  125. package/src/sdk/broker/session-index.ts +121 -17
  126. package/src/sdk/broker/session-scope.ts +272 -0
  127. package/src/sdk/broker/spawn-authority.ts +1031 -0
  128. package/src/sdk/broker/spawn-substrate.ts +548 -0
  129. package/src/sdk/broker/state-version.ts +26 -14
  130. package/src/sdk/broker/transport.ts +1 -0
  131. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  132. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  133. package/src/sdk/bus/chat-daemon-runtime.ts +35 -4
  134. package/src/sdk/bus/index.ts +51 -4
  135. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  136. package/src/sdk/cli/index.ts +1 -0
  137. package/src/sdk/cli/master-cli.ts +259 -0
  138. package/src/sdk/cli/rows.ts +11 -8
  139. package/src/sdk/cli/session-cli.ts +264 -5
  140. package/src/sdk/client/client.ts +14 -8
  141. package/src/sdk/host/control/dispatch.ts +99 -38
  142. package/src/sdk/host/control/index.ts +2 -0
  143. package/src/sdk/host/control/operations.ts +2 -0
  144. package/src/sdk/host/control/runtime-gate.ts +203 -11
  145. package/src/sdk/host/host.ts +27 -2
  146. package/src/sdk/host/session-runtime.test.ts +210 -6
  147. package/src/sdk/host/session-runtime.ts +291 -49
  148. package/src/sdk/lifecycle/broker-client.ts +28 -0
  149. package/src/sdk/lifecycle/service.ts +279 -20
  150. package/src/sdk/lifecycle-session.ts +10 -2
  151. package/src/sdk/protocol/operation-inventory.generated.json +51 -0
  152. package/src/sdk/protocol/operation-registry.ts +13 -3
  153. package/src/sdk/router/session-router.ts +67 -59
  154. package/src/sdk/session.ts +357 -155
  155. package/src/sdk/startup-capability.ts +1 -0
  156. package/src/session/agent-session.ts +640 -103
  157. package/src/session/blob-store.ts +15 -5
  158. package/src/session/credential-store-errors.ts +2 -0
  159. package/src/session/internal/managed-session-storage.ts +153 -78
  160. package/src/session/session-manager.ts +337 -24
  161. package/src/session/yield-queue.ts +24 -10
  162. package/src/setup/frictionless-onboarding.ts +2 -0
  163. package/src/telemetry/events.ts +1138 -12
  164. package/src/tools/bash.ts +256 -42
  165. package/src/tools/gh.ts +104 -48
  166. package/src/tools/github-cache.ts +2 -2
  167. package/src/tools/index.ts +4 -0
@@ -9,6 +9,7 @@ import * as git from "../../utils/git";
9
9
  import { sanitizeStatusText } from "../shared";
10
10
  import { getContextUsageLevel, getContextUsageThemeColor } from "./status-line/context-thresholds";
11
11
  import { resolveCurrentBranch } from "./status-line/git-utils";
12
+ import { shortenModelId } from "./status-line/model-name";
12
13
 
13
14
  /**
14
15
  * Footer component that shows pwd, token stats, and context usage
@@ -191,8 +192,9 @@ export class FooterComponent implements Component {
191
192
 
192
193
  let statsLeft = statsParts.join(" ");
193
194
 
194
- // Add model name on the right side, plus thinking level if model supports it
195
- const modelName = state.model?.id || "no-model";
195
+ // Add model name on the right side, plus thinking level if model supports it.
196
+ // Same shortest-recognizable form the status rail renders.
197
+ const modelName = shortenModelId(state.model?.id ?? state.model?.name);
196
198
 
197
199
  // Add thinking level hint when the current model advertises supported efforts
198
200
  let rightSide = modelName;
@@ -1,9 +1,55 @@
1
- import { Container, matchesKey, Spacer, TruncatedText } from "@gajae-code/tui";
1
+ import { Container, matchesKey, type SelectItem, SelectList, Spacer, TruncatedText } from "@gajae-code/tui";
2
2
  import type { OnboardingProfile } from "../../setup/frictionless-onboarding";
3
- import { theme } from "../theme/theme";
3
+ import { getSelectListTheme, theme } from "../theme/theme";
4
+ import { UI_LANGUAGES, type UiLanguage } from "../ui-language";
4
5
  import { matchesSelectCancel } from "../utils/keybinding-matchers";
5
6
  import { DynamicBorder } from "./dynamic-border";
6
7
 
8
+ export const UI_LANGUAGE_CHOICES: readonly { value: UiLanguage; label: string }[] = [
9
+ { value: "en", label: "English" },
10
+ { value: "ko", label: "한국어" },
11
+ { value: "zh", label: "简体中文" },
12
+ { value: "ja", label: "日本語" },
13
+ ];
14
+
15
+ export class InterfaceLanguageSelectorComponent extends Container {
16
+ readonly #selectList: SelectList;
17
+
18
+ constructor(onSelect: (language: UiLanguage) => void, onCancel: () => void) {
19
+ super();
20
+ this.addChild(new DynamicBorder());
21
+ this.addChild(new Spacer(1));
22
+ this.addChild(
23
+ new TruncatedText(
24
+ theme.bold(
25
+ "Choose your interface language / 언어를 선택하세요 / 选择界面语言 / インターフェース言語を選択",
26
+ ),
27
+ ),
28
+ );
29
+ this.addChild(new Spacer(1));
30
+ const items: SelectItem[] = UI_LANGUAGE_CHOICES.map(choice => ({ value: choice.value, label: choice.label }));
31
+ this.#selectList = new SelectList(items, items.length, getSelectListTheme());
32
+ this.#selectList.onSelect = item => {
33
+ if (UI_LANGUAGES.includes(item.value as UiLanguage)) onSelect(item.value as UiLanguage);
34
+ };
35
+ this.#selectList.onCancel = onCancel;
36
+ this.addChild(this.#selectList);
37
+ this.addChild(new Spacer(1));
38
+ this.addChild(new TruncatedText(theme.fg("dim", "↑/↓ select · Enter choose · Esc use English")));
39
+ this.addChild(new DynamicBorder());
40
+ }
41
+
42
+ handleInput(keyData: string): void {
43
+ // The app.exit action is owned by the parent editor and is not a TUI
44
+ // selector key id. Ctrl-D is its default byte when the editor is empty.
45
+ if (keyData === "\x04") {
46
+ this.#selectList.onCancel?.();
47
+ return;
48
+ }
49
+ this.#selectList.handleInput(keyData);
50
+ }
51
+ }
52
+
7
53
  export type FrictionlessOnboardingAction =
8
54
  | "analyze"
9
55
  | "apply"
@@ -56,6 +56,7 @@ export class OAuthSelectorComponent extends Container {
56
56
  #onCancelCallback: () => void;
57
57
  #statusMessage: string | undefined;
58
58
  #validateAuthCallback?: (providerId: string) => Promise<boolean>;
59
+ #onValidationError?: (error: unknown) => boolean;
59
60
  #requestRenderCallback?: () => void;
60
61
  #authState: Map<string, "checking" | "valid" | "invalid"> = new Map();
61
62
  #externalCredentialCandidates: ImportableCredential[] = [];
@@ -76,6 +77,7 @@ export class OAuthSelectorComponent extends Container {
76
77
  onCancel: () => void,
77
78
  options?: {
78
79
  validateAuth?: (providerId: string) => Promise<boolean>;
80
+ onValidationError?: (error: unknown) => boolean;
79
81
  requestRender?: () => void;
80
82
  externalCredentialCandidates?: ImportableCredential[];
81
83
  } & Partial<OAuthSelectorAccountOptions>,
@@ -86,6 +88,7 @@ export class OAuthSelectorComponent extends Container {
86
88
  this.#onSelectCallback = onSelect;
87
89
  this.#onCancelCallback = onCancel;
88
90
  this.#validateAuthCallback = options?.validateAuth;
91
+ this.#onValidationError = options?.onValidationError;
89
92
  this.#requestRenderCallback = options?.requestRender;
90
93
  this.#externalCredentialCandidates = options?.externalCredentialCandidates ?? [];
91
94
  this.#accountProviderId = options?.accountProviderId;
@@ -194,7 +197,9 @@ export class OAuthSelectorComponent extends Container {
194
197
  let isValid = false;
195
198
  try {
196
199
  isValid = await this.#validateAuthCallback(providerId);
197
- } catch {
200
+ } catch (error) {
201
+ if (generation !== this.#validationGeneration) return;
202
+ if (this.#onValidationError?.(error)) return;
198
203
  isValid = false;
199
204
  }
200
205
  if (generation !== this.#validationGeneration) return;
@@ -1009,6 +1009,7 @@ export class SettingsSelectorComponent extends Container {
1009
1009
  options = [
1010
1010
  { value: "en", label: uiString(language, "settings.language.english") },
1011
1011
  { value: "ko", label: uiString(language, "settings.language.korean") },
1012
+ { value: "zh", label: uiString(language, "settings.language.chinese") },
1012
1013
  { value: "ja", label: uiString(language, "settings.language.japanese") },
1013
1014
  ];
1014
1015
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Shortest recognizable model label, derived purely from the model id.
3
+ *
4
+ * The rail and the footer both render this form, so a narrow terminal spends
5
+ * its cells on the part of the id that actually identifies the model:
6
+ *
7
+ * `anthropic/claude-sonnet-4-5-20250929` -> `sonnet-4.5`
8
+ * `openai/gpt-4o-2024-05-13` -> `gpt-4o`
9
+ * `google/gemini-3-pro` -> `gemini-3-pro`
10
+ * `qwen2.5:7b` -> `qwen2.5:7b`
11
+ *
12
+ * Deliberately a pure heuristic with no curated override table: every provider
13
+ * in `models.json` must degrade sanely without per-model maintenance. The
14
+ * function never returns an empty string — an id that the heuristic would
15
+ * strip to nothing falls back to the previous, longer form.
16
+ */
17
+
18
+ /**
19
+ * Vendor family words that carry no information once the provider prefix is
20
+ * gone. Deliberately minimal: `gpt` and `gemini` are part of how those models
21
+ * are named and are kept.
22
+ */
23
+ const VENDOR_FAMILY_WORDS: ReadonlySet<string> = new Set(["claude"]);
24
+
25
+ /** Trailing snapshot dates and build stamps: `-20250929`, `-2024-05-13`, `-v2`. */
26
+ const DATE_OR_BUILD_SUFFIX = /-(?:\d{4}-\d{2}-\d{2}|\d{8}|\d{6}|v\d+(?:\.\d+)*)$/;
27
+
28
+ /** Split version parts read as one number: `sonnet-4-5` -> `sonnet-4.5`. */
29
+ const SPLIT_VERSION = /(\d)-(\d)/g;
30
+
31
+ export const NO_MODEL_LABEL = "no-model";
32
+
33
+ export function shortenModelId(rawId: string | undefined | null): string {
34
+ const raw = (rawId ?? "").trim();
35
+ if (!raw) return NO_MODEL_LABEL;
36
+
37
+ // Provider prefix: `anthropic/claude-…`, `openrouter/anthropic/claude-…`.
38
+ const afterProvider = raw.slice(raw.lastIndexOf("/") + 1).trim() || raw;
39
+
40
+ const withoutDate = afterProvider.replace(DATE_OR_BUILD_SUFFIX, "") || afterProvider;
41
+
42
+ let name = withoutDate;
43
+ const dashIndex = name.indexOf("-");
44
+ if (dashIndex > 0) {
45
+ const head = name.slice(0, dashIndex).toLowerCase();
46
+ const rest = name.slice(dashIndex + 1);
47
+ if (rest && VENDOR_FAMILY_WORDS.has(head)) name = rest;
48
+ }
49
+
50
+ name = name.replace(SPLIT_VERSION, "$1.$2");
51
+
52
+ return name || withoutDate || afterProvider;
53
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Narrow-width survival row for the status rail.
3
+ *
4
+ * The rail evicts segments continuously as the terminal narrows. Without this
5
+ * row the last thing left at very small widths is the `…+N` overflow marker —
6
+ * the three things a user actually needs (how much context is left, whether a
7
+ * goal is running, which model is answering) are exactly what the generic
8
+ * eviction throws away first or last by accident.
9
+ *
10
+ * This row keeps them, in priority order, by stepping each item down its own
11
+ * ladder instead of dropping it:
12
+ *
13
+ * full form -> shortened form -> single glyph -> dropped
14
+ *
15
+ * Context % is never dropped and is the last survivor; the model name is
16
+ * dropped first of the three. The row is rendered as bare colored text with no
17
+ * group padding or end caps, so — like the overflow marker — it can survive at
18
+ * widths where a normal segment group cannot.
19
+ */
20
+
21
+ import { visibleWidth } from "@gajae-code/tui";
22
+ import { formatNumber } from "@gajae-code/utils";
23
+ import { theme } from "../../theme/theme";
24
+ import { getContextUsageLevel, getContextUsageThemeColor } from "./context-thresholds";
25
+ import { shortenModelId } from "./model-name";
26
+ import { goalStatusDisplay } from "./segments";
27
+ import type { SegmentContext } from "./types";
28
+
29
+ const SEPARATOR = "·";
30
+
31
+ /** Which priority items the active layout is supposed to be carrying. */
32
+ export interface PriorityItemSet {
33
+ context: boolean;
34
+ goal: boolean;
35
+ model: boolean;
36
+ }
37
+
38
+ function contextForms(ctx: SegmentContext): { full: string; short: string } | null {
39
+ const window = ctx.contextWindow;
40
+ if (!(window > 0)) return null;
41
+
42
+ const raw = ctx.contextPercent;
43
+ const pct = typeof raw === "number" && Number.isFinite(raw) ? raw : null;
44
+ const color = pct === null ? "statusLineContext" : getContextUsageThemeColor(getContextUsageLevel(pct, window));
45
+
46
+ return {
47
+ full: theme.fg(color, `${pct === null ? "?" : `${pct.toFixed(1)}%`}/${formatNumber(window)}`),
48
+ short: theme.fg(color, pct === null ? "?" : `${Math.round(pct)}%`),
49
+ };
50
+ }
51
+
52
+ function goalForms(ctx: SegmentContext): { label: string; glyph: string } | null {
53
+ const display = goalStatusDisplay(ctx);
54
+ if (!display) return null;
55
+
56
+ const glyph = display.icon || "G";
57
+ return {
58
+ label: theme.fg(display.color, display.icon ? `${display.icon} Goal` : "Goal"),
59
+ glyph: theme.fg(display.color, glyph),
60
+ };
61
+ }
62
+
63
+ function modelForms(ctx: SegmentContext): { short: string; glyph: string } {
64
+ const model = ctx.session.state.model;
65
+ const name = shortenModelId(model?.id ?? model?.name);
66
+ return {
67
+ short: theme.fg("statusLineModel", name),
68
+ glyph: theme.fg("statusLineModel", theme.icon.model || name.slice(0, 1)),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Widest priority row that fits `width`, or null when even the minimal form
74
+ * cannot fit (callers then fall back to the overflow marker).
75
+ *
76
+ * Returns null when neither context nor goal is available: the model name alone
77
+ * is the lowest-priority item and never justifies replacing the normal rail.
78
+ */
79
+ export function buildPriorityRow(ctx: SegmentContext, width: number, include: PriorityItemSet): string | null {
80
+ if (width <= 0) return null;
81
+
82
+ const context = include.context ? contextForms(ctx) : null;
83
+ const goal = include.goal ? goalForms(ctx) : null;
84
+ const model = include.model ? modelForms(ctx) : null;
85
+ if (!context && !goal) return null;
86
+
87
+ // Ordered widest-first. Every item degrades through its own forms before any
88
+ // item is dropped, and drops run lowest-priority first (model, then goal).
89
+ const ladder: readonly (readonly (string | null)[])[] = [
90
+ [context?.full ?? null, goal?.label ?? null, model?.short ?? null],
91
+ [context?.full ?? null, goal?.glyph ?? null, model?.short ?? null],
92
+ [context?.full ?? null, goal?.glyph ?? null, model?.glyph ?? null],
93
+ [context?.short ?? null, goal?.glyph ?? null, model?.glyph ?? null],
94
+ [context?.short ?? null, goal?.glyph ?? null, null],
95
+ [context?.short ?? null, null, null],
96
+ ];
97
+
98
+ const separator = theme.fg("dim", SEPARATOR);
99
+ for (const candidate of ladder) {
100
+ const parts = candidate.filter((part): part is string => part !== null);
101
+ if (parts.length === 0) continue;
102
+ const row = parts.join(separator);
103
+ if (visibleWidth(row) <= width) return row;
104
+ }
105
+
106
+ return null;
107
+ }
@@ -8,6 +8,7 @@ import { shortenPath } from "../../../tools/render-utils";
8
8
  import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
9
9
  import { sanitizeStatusText } from "../../shared";
10
10
  import { getContextUsageLevel, getContextUsageThemeColor } from "./context-thresholds";
11
+ import { shortenModelId } from "./model-name";
11
12
  import type { RenderedSegment, SegmentContext, StatusLineSegment, StatusLineSegmentId } from "./types";
12
13
 
13
14
  export type { SegmentContext } from "./types";
@@ -99,10 +100,10 @@ const modelSegment: StatusLineSegment = {
99
100
  const state = ctx.session.state;
100
101
  const opts = ctx.options.model ?? {};
101
102
 
102
- let modelName = state.model?.name || state.model?.id || "no-model";
103
- if (modelName.startsWith("Claude ")) {
104
- modelName = modelName.slice(7);
105
- }
103
+ // Shortest recognizable id-derived form (`sonnet-4.5`), the same label the
104
+ // footer renders, so a narrow rail never spends cells on a provider prefix
105
+ // or a snapshot date.
106
+ const modelName = shortenModelId(state.model?.id ?? state.model?.name);
106
107
 
107
108
  let content = withIcon(theme.icon.model, modelName);
108
109
 
@@ -148,9 +149,16 @@ function formatGoalUsage(current: number): string {
148
149
  return used;
149
150
  }
150
151
 
151
- function renderGoalMode(ctx: SegmentContext, mode: { enabled: boolean; paused: boolean }): RenderedSegment {
152
- const goal = ctx.session.getGoalModeState()?.goal;
153
- const status = goal?.status ?? (mode.paused ? "paused" : "active");
152
+ /**
153
+ * Icon and color for the active goal indicator, or null when goal mode is not
154
+ * showing. Shared with the narrow-width priority row so the compact glyph keeps
155
+ * the same status color as the full label.
156
+ */
157
+ export function goalStatusDisplay(ctx: SegmentContext): { icon: string; color: ThemeColor } | null {
158
+ const mode = ctx.goalMode;
159
+ if (!mode || (!mode.enabled && !mode.paused)) return null;
160
+
161
+ const status = ctx.session.getGoalModeState?.()?.goal?.status ?? (mode.paused ? "paused" : "active");
154
162
 
155
163
  let icon: string = theme.icon.goal;
156
164
  let color: ThemeColor = "accent";
@@ -170,13 +178,19 @@ function renderGoalMode(ctx: SegmentContext, mode: { enabled: boolean; paused: b
170
178
  default:
171
179
  break;
172
180
  }
181
+ return { icon, color };
182
+ }
183
+
184
+ function renderGoalMode(ctx: SegmentContext, mode: { enabled: boolean; paused: boolean }): RenderedSegment {
185
+ const display = goalStatusDisplay(ctx) ?? { icon: theme.icon.goal, color: "accent" as ThemeColor };
186
+ const goal = ctx.session.getGoalModeState?.()?.goal;
173
187
 
174
- const parts: string[] = [withIcon(icon, "Goal")];
188
+ const parts: string[] = [withIcon(display.icon, "Goal")];
175
189
  const showUsage = ctx.session.settings.get("goal.statusInFooter") === true;
176
190
  if (showUsage && goal) {
177
191
  parts.push(formatGoalUsage(goal.tokensUsed));
178
192
  }
179
- return { content: theme.fg(color, parts.join(" ")), visible: true };
193
+ return { content: theme.fg(display.color, parts.join(" ")), visible: mode.enabled || mode.paused };
180
194
  }
181
195
 
182
196
  const modeSegment: StatusLineSegment = {
@@ -28,6 +28,7 @@ import {
28
28
  resolveCurrentBranch,
29
29
  } from "./status-line/git-utils";
30
30
  import { getPreset } from "./status-line/presets";
31
+ import { buildPriorityRow, type PriorityItemSet } from "./status-line/priority-row";
31
32
  import { renderSegment, type SegmentContext } from "./status-line/segments";
32
33
  import { getSeparator } from "./status-line/separators";
33
34
  import { calculateTokensPerSecond } from "./status-line/token-rate";
@@ -132,6 +133,8 @@ interface CollectedStatusSegments {
132
133
  left: string[];
133
134
  leftSegIds: StatusLineSegmentId[];
134
135
  right: string[];
136
+ /** Parallel to `right`; null for action hints, job counts and the version tag. */
137
+ rightSegIds: (StatusLineSegmentId | null)[];
135
138
  previewHighlightSegment: StatusLineSegmentId | undefined;
136
139
  sessionAccent: boolean | undefined;
137
140
  leftSepWidth: number;
@@ -819,6 +822,7 @@ export class StatusLineComponent implements Component {
819
822
  }
820
823
 
821
824
  const right: string[] = [];
825
+ const rightSegIds: (StatusLineSegmentId | null)[] = [];
822
826
  const actionHints =
823
827
  effectiveSettings.showActionHints === false
824
828
  ? []
@@ -833,9 +837,13 @@ export class StatusLineComponent implements Component {
833
837
  const rendered = renderSegment(segId, ctx);
834
838
  if (rendered.visible && rendered.content) {
835
839
  right.push(highlightSegment(segId, rendered.content));
840
+ rightSegIds.push(segId);
836
841
  }
837
842
  }
838
- right.push(...actionHints.map(hint => hint.content));
843
+ for (const hint of actionHints) {
844
+ right.push(hint.content);
845
+ rightSegIds.push(null);
846
+ }
839
847
 
840
848
  const runningBackgroundJobs =
841
849
  this.session.getAsyncJobSnapshot()?.running.filter(job => job.metadata?.monitor !== true).length ?? 0;
@@ -843,9 +851,11 @@ export class StatusLineComponent implements Component {
843
851
  const icon = theme.icon.agents ? `${theme.icon.agents} ` : "";
844
852
  const label = `${formatCount("job", runningBackgroundJobs)} running`;
845
853
  right.push(theme.fg("statusLineSubagents", `${icon}${label}`));
854
+ rightSegIds.push(null);
846
855
  }
847
856
  if (this.#version) {
848
857
  right.push(theme.fg("dim", `v${this.#version}`));
858
+ rightSegIds.push(null);
849
859
  }
850
860
 
851
861
  return {
@@ -857,6 +867,7 @@ export class StatusLineComponent implements Component {
857
867
  left,
858
868
  leftSegIds,
859
869
  right,
870
+ rightSegIds,
860
871
  previewHighlightSegment,
861
872
  sessionAccent: effectiveSettings.sessionAccent,
862
873
  leftSepWidth: visibleWidth(separatorDef.left),
@@ -872,22 +883,108 @@ export class StatusLineComponent implements Component {
872
883
  return Math.max(1, Math.min(3, Math.trunc(raw)));
873
884
  }
874
885
 
886
+ /**
887
+ * Which priority items this layout is carrying, and where.
888
+ *
889
+ * Context % has no segment of its own in most presets — it rides inside the
890
+ * `model` segment — so `model` inherits the context rank whenever it does.
891
+ * Items whose data is absent (no context window, no active goal) carry no
892
+ * priority at all, which keeps a rail with nothing to protect evicting
893
+ * exactly as it always did.
894
+ */
895
+ #priorityItems(seg: CollectedStatusSegments): {
896
+ include: PriorityItemSet;
897
+ inlineContextPct: boolean;
898
+ } {
899
+ const collected = (id: StatusLineSegmentId): boolean =>
900
+ seg.leftSegIds.includes(id) || seg.rightSegIds.includes(id);
901
+ const inlineContextPct =
902
+ seg.ctx.options.model?.showContextPercent !== false && seg.ctx.contextPctSegmentActive !== true;
903
+ const goalMode = seg.ctx.goalMode;
904
+
905
+ return {
906
+ inlineContextPct,
907
+ include: {
908
+ context:
909
+ seg.ctx.contextWindow > 0 && (collected("context_pct") || (collected("model") && inlineContextPct)),
910
+ goal: collected("mode") && goalMode !== null && (goalMode.enabled || goalMode.paused),
911
+ model: collected("model"),
912
+ },
913
+ };
914
+ }
915
+
916
+ /**
917
+ * Eviction rank: 0 is ordinary telemetry and goes first, 3 is context % and
918
+ * goes last. The model name outranks telemetry but loses to the goal
919
+ * indicator, matching context % > goal > model.
920
+ */
921
+ #priorityRanker(seg: CollectedStatusSegments): (id: StatusLineSegmentId | null) => number {
922
+ const { include, inlineContextPct } = this.#priorityItems(seg);
923
+ const anyPriority = include.context || include.goal;
924
+
925
+ return (id: StatusLineSegmentId | null): number => {
926
+ if (id === null || !anyPriority) return 0;
927
+ if (id === "context_pct") return include.context ? 3 : 0;
928
+ if (id === "model") {
929
+ if (include.context && inlineContextPct) return 3;
930
+ return include.model ? 1 : 0;
931
+ }
932
+ if (id === "mode") return include.goal ? 2 : 0;
933
+ return 0;
934
+ };
935
+ }
936
+
937
+ /**
938
+ * Priority row for a layout that could not keep everything the user needs on
939
+ * screen, or null when the normal rail already carries context %, the goal
940
+ * indicator and the model name.
941
+ *
942
+ * Context % has no segment of its own in most presets — it rides inside the
943
+ * `model` segment — so losing `model` silently loses the highest-priority
944
+ * item too. Survival is therefore resolved per item, not per segment.
945
+ */
946
+ #priorityRowFor(
947
+ seg: CollectedStatusSegments,
948
+ survivingLeftIds: readonly (StatusLineSegmentId | null)[],
949
+ survivingRightIds: readonly (StatusLineSegmentId | null)[],
950
+ width: number,
951
+ ): string | null {
952
+ const survived = (id: StatusLineSegmentId): boolean =>
953
+ survivingLeftIds.includes(id) || survivingRightIds.includes(id);
954
+
955
+ const { include, inlineContextPct } = this.#priorityItems(seg);
956
+ if (!include.context && !include.goal) return null;
957
+
958
+ const contextLost = include.context && !survived("context_pct") && !(inlineContextPct && survived("model"));
959
+ const goalLost = include.goal && !survived("mode");
960
+ const modelLost = include.model && !survived("model");
961
+ if (!contextLost && !goalLost && !modelLost) return null;
962
+
963
+ return buildPriorityRow(seg.ctx, width, include);
964
+ }
965
+
875
966
  #buildStatusLine(width: number, precollected?: CollectedStatusSegments): string {
876
967
  const seg = precollected ?? this.#collectStatusSegments(width, this.#resolveSettings());
877
968
  const { ctx, separatorDef, bgAnsi, fgAnsi, sepAnsi, previewHighlightSegment } = seg;
878
969
  const { leftSepWidth, rightSepWidth, leftCapWidth, rightCapWidth } = seg;
879
970
  const topFillWidth = Math.max(0, width);
880
971
 
972
+ const rank = this.#priorityRanker(seg);
973
+
881
974
  /**
882
- * Evict against `budget` using the existing configured-order semantics
883
- * (shrink path, then pop right, then pop left) and report how many
884
- * segments were dropped. Runs from the original segment lists each time so
885
- * the marker reservation can be recomputed without compounding evictions.
975
+ * Evict against `budget`: shrink the path first, then drop the
976
+ * lowest-priority segment still standing — right side before left, tail
977
+ * first within a side, which is the historical order for everything that
978
+ * carries no priority. Context %, the goal indicator and the model name
979
+ * outrank ordinary telemetry and are therefore the last to go. Runs from
980
+ * the original segment lists each time so the marker reservation can be
981
+ * recomputed without compounding evictions.
886
982
  */
887
983
  const layout = (budget: number) => {
888
984
  const left = [...seg.left];
889
985
  const leftIds = [...seg.leftSegIds];
890
986
  const right = [...seg.right];
987
+ const rightIds = [...seg.rightSegIds];
891
988
  let leftWidth = this.#groupWidth(left, leftCapWidth, leftSepWidth);
892
989
  let rightWidth = this.#groupWidth(right, rightCapWidth, rightSepWidth);
893
990
  const totalWidth = () => leftWidth + rightWidth + (left.length > 0 && right.length > 0 ? 1 : 0);
@@ -905,19 +1002,42 @@ export class StatusLineComponent implements Component {
905
1002
  leftWidth = this.#groupWidth(left, leftCapWidth, leftSepWidth);
906
1003
  }
907
1004
  }
908
- while (totalWidth() > budget && right.length > 0) {
909
- right.pop();
910
- dropped += 1;
911
- rightWidth = this.#groupWidth(right, rightCapWidth, rightSepWidth);
912
- }
913
- while (totalWidth() > budget && left.length > 0) {
914
- left.pop();
915
- leftIds.pop();
1005
+ // Lowest rank loses first; equal ranks fall back to the historical
1006
+ // right-then-left, tail-first order.
1007
+ while (totalWidth() > budget && (left.length > 0 || right.length > 0)) {
1008
+ let victimSide: "left" | "right" | null = null;
1009
+ let victimIndex = -1;
1010
+ let victimRank = Number.POSITIVE_INFINITY;
1011
+ for (let index = right.length - 1; index >= 0; index -= 1) {
1012
+ const candidate = rank(rightIds[index]);
1013
+ if (candidate < victimRank) {
1014
+ victimRank = candidate;
1015
+ victimSide = "right";
1016
+ victimIndex = index;
1017
+ }
1018
+ }
1019
+ for (let index = left.length - 1; index >= 0; index -= 1) {
1020
+ const candidate = rank(leftIds[index]);
1021
+ if (candidate < victimRank) {
1022
+ victimRank = candidate;
1023
+ victimSide = "left";
1024
+ victimIndex = index;
1025
+ }
1026
+ }
1027
+ if (victimSide === null) break;
1028
+ if (victimSide === "right") {
1029
+ right.splice(victimIndex, 1);
1030
+ rightIds.splice(victimIndex, 1);
1031
+ rightWidth = this.#groupWidth(right, rightCapWidth, rightSepWidth);
1032
+ } else {
1033
+ left.splice(victimIndex, 1);
1034
+ leftIds.splice(victimIndex, 1);
1035
+ leftWidth = this.#groupWidth(left, leftCapWidth, leftSepWidth);
1036
+ }
916
1037
  dropped += 1;
917
- leftWidth = this.#groupWidth(left, leftCapWidth, leftSepWidth);
918
1038
  }
919
1039
  }
920
- return { left, right, leftWidth, rightWidth, dropped };
1040
+ return { left, right, leftIds, rightIds, leftWidth, rightWidth, dropped };
921
1041
  };
922
1042
 
923
1043
  // First pass with the full budget establishes whether anything is lost at
@@ -939,6 +1059,11 @@ export class StatusLineComponent implements Component {
939
1059
  }
940
1060
  }
941
1061
  const marker = this.#renderOverflowMarker(placed.dropped, reserved);
1062
+ // Nothing survived that the user actually needs? Spend the rail on the
1063
+ // priority row instead of an overflow marker. Suppressing the marker here
1064
+ // is deliberate: at these widths its cells are worth more as context %.
1065
+ const priorityRow = this.#priorityRowFor(seg, placed.leftIds, placed.rightIds, topFillWidth);
1066
+ if (priorityRow !== null) return priorityRow;
942
1067
 
943
1068
  const leftGroup = this.#renderStatusGroup(placed.left, "left", separatorDef, bgAnsi, fgAnsi, sepAnsi);
944
1069
  const rightGroup = this.#renderStatusGroup(placed.right, "right", separatorDef, bgAnsi, fgAnsi, sepAnsi);
@@ -1078,6 +1203,17 @@ export class StatusLineComponent implements Component {
1078
1203
  if (row.length === 0) packedRows.splice(index, 1);
1079
1204
  }
1080
1205
 
1206
+ // Wrapping is supposed to be the lossless path. When even `maxRows` rows
1207
+ // cannot hold everything, the priority row beats a partial rail plus a
1208
+ // count of what is missing.
1209
+ if (dropped > 0) {
1210
+ const priorityRow = this.#priorityRowFor(seg, [], [], topFillWidth);
1211
+ if (priorityRow !== null) {
1212
+ this.#renderedRowsCache = { key: cacheKey, rows: [priorityRow] };
1213
+ return [priorityRow];
1214
+ }
1215
+ }
1216
+
1081
1217
  // Reserve marker cells on whichever row ends up last, evicting from its
1082
1218
  // tail until the cue fits. Never opens a new row: that would contradict
1083
1219
  // maxRows. Emptying that row is fine -- the marker then occupies it alone.
@@ -420,6 +420,34 @@ export class InputController {
420
420
  if (this.ctx.cancelPendingSubmission()) {
421
421
  return true;
422
422
  }
423
+ if (options.processes && this.ctx.session.isBashRunning) {
424
+ this.ctx.session.abortBash();
425
+ return true;
426
+ }
427
+ if (options.processes && this.ctx.session.isEvalRunning) {
428
+ this.ctx.session.abortEval();
429
+ return true;
430
+ }
431
+ // A queued steer on a live stream is owned by the steering branch below,
432
+ // which silently consumes it and auto-continues. This branch counts the same
433
+ // queue as cancellable work, and a streaming turn always has the indicator
434
+ // mounted, so without this hand-off it short-circuited first and turned the
435
+ // documented steer-on-interrupt into a loud abort that dumped the steer back
436
+ // into the composer. `cancelPendingSubmission()` above still wins: a
437
+ // submission that has not started yet is cancelled before the stream owns
438
+ // the key, and one that has started returns false there by design.
439
+ if (
440
+ options.streaming === true &&
441
+ this.ctx.session.isStreaming &&
442
+ this.ctx.session.hasQueuedSteering &&
443
+ !this.ctx.session.isBashRunning &&
444
+ !this.ctx.session.isEvalRunning &&
445
+ !this.#steerConsumePending
446
+ ) {
447
+ this.#steerConsumePending = true;
448
+ void this.#abortInteractive({ silent: true });
449
+ return true;
450
+ }
423
451
  // Only count queues this handler can actually drain. The aggregate
424
452
  // `queuedMessageCount` also counts hidden next-turn context, which
425
453
  // `restoreQueuedMessagesToEditor()` never returns and which survives turn