@sayknow-cli/coding-agent 0.6.4 → 0.6.5

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 (81) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/README.md +2 -2
  3. package/dist/types/config/keybindings.d.ts +1 -1
  4. package/dist/types/config/settings-schema.d.ts +7 -3
  5. package/dist/types/i18n/messages/en.d.ts +16 -0
  6. package/dist/types/modes/components/session-selector.d.ts +7 -3
  7. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  8. package/dist/types/modes/components/user-message-selector.d.ts +5 -2
  9. package/dist/types/modes/components/user-message.d.ts +3 -1
  10. package/dist/types/modes/components/welcome.d.ts +49 -6
  11. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  12. package/dist/types/modes/controllers/selector-controller.d.ts +5 -0
  13. package/dist/types/modes/interactive-mode.d.ts +7 -1
  14. package/dist/types/modes/theme/defaults/index.d.ts +342 -0
  15. package/dist/types/modes/theme/theme.d.ts +5 -1
  16. package/dist/types/modes/types.d.ts +1 -1
  17. package/dist/types/session/session-manager.d.ts +9 -0
  18. package/dist/types/session/session-stars.d.ts +15 -0
  19. package/dist/types/tui/output-block.d.ts +12 -1
  20. package/dist/types/tui/utils.d.ts +1 -3
  21. package/dist/types/utils/session-title-generation.d.ts +6 -0
  22. package/dist/types/utils/title-generator.d.ts +11 -2
  23. package/package.json +7 -7
  24. package/scripts/generate-sdk-operation-inventory.ts +8 -0
  25. package/src/cli/session-picker.ts +4 -0
  26. package/src/config/keybindings.ts +1 -1
  27. package/src/config/settings-schema.ts +4 -3
  28. package/src/i18n/messages/de.messages.ts +4 -1
  29. package/src/i18n/messages/de.settings.ts +2 -0
  30. package/src/i18n/messages/de.ts +16 -0
  31. package/src/i18n/messages/en.ts +16 -0
  32. package/src/i18n/messages/es.messages.ts +4 -1
  33. package/src/i18n/messages/es.settings.ts +2 -0
  34. package/src/i18n/messages/es.ts +16 -0
  35. package/src/i18n/messages/fr.messages.ts +4 -1
  36. package/src/i18n/messages/fr.settings.ts +2 -0
  37. package/src/i18n/messages/fr.ts +16 -0
  38. package/src/i18n/messages/ja.messages.ts +4 -1
  39. package/src/i18n/messages/ja.settings.ts +2 -0
  40. package/src/i18n/messages/ja.ts +16 -0
  41. package/src/i18n/messages/ko.messages.ts +4 -1
  42. package/src/i18n/messages/ko.settings.ts +2 -0
  43. package/src/i18n/messages/ko.ts +16 -0
  44. package/src/i18n/messages/zh.messages.ts +2 -1
  45. package/src/i18n/messages/zh.settings.ts +2 -0
  46. package/src/i18n/messages/zh.ts +16 -0
  47. package/src/internal-urls/docs-index.generated.ts +10 -10
  48. package/src/lsp/render.ts +0 -1
  49. package/src/modes/components/session-selector.ts +71 -21
  50. package/src/modes/components/sessions-dashboard.ts +8 -5
  51. package/src/modes/components/settings-selector.ts +1 -0
  52. package/src/modes/components/status-line/presets.ts +5 -5
  53. package/src/modes/components/status-line/separators.ts +4 -0
  54. package/src/modes/components/tool-execution.ts +6 -19
  55. package/src/modes/components/tool-status-header.ts +103 -12
  56. package/src/modes/components/user-message-selector.ts +57 -15
  57. package/src/modes/components/user-message.ts +25 -15
  58. package/src/modes/components/welcome.ts +374 -478
  59. package/src/modes/controllers/command-controller.ts +56 -7
  60. package/src/modes/controllers/input-controller.ts +4 -0
  61. package/src/modes/controllers/selector-controller.ts +91 -10
  62. package/src/modes/interactive-mode.ts +101 -12
  63. package/src/modes/theme/defaults/glow-octopus.json +114 -0
  64. package/src/modes/theme/defaults/index.ts +6 -0
  65. package/src/modes/theme/defaults/ink-octopus.json +114 -0
  66. package/src/modes/theme/defaults/violet-octopus.json +114 -0
  67. package/src/modes/theme/theme.ts +18 -2
  68. package/src/modes/types.ts +1 -1
  69. package/src/prompts/system/title-system.md +1 -1
  70. package/src/sdk/protocol/operation-inventory.generated.json +40 -0
  71. package/src/session/agent-session.ts +12 -0
  72. package/src/session/internal/managed-session-scope.ts +10 -0
  73. package/src/session/session-manager.ts +26 -1
  74. package/src/session/session-stars.ts +104 -0
  75. package/src/slash-commands/builtin-registry.ts +104 -4
  76. package/src/tools/debug.ts +0 -1
  77. package/src/tools/fetch.ts +0 -1
  78. package/src/tui/output-block.ts +29 -61
  79. package/src/tui/utils.ts +1 -8
  80. package/src/utils/session-title-generation.ts +29 -0
  81. package/src/utils/title-generator.ts +46 -2
package/src/lsp/render.ts CHANGED
@@ -186,7 +186,6 @@ export function renderResult(
186
186
  { label: theme.fg("toolTitle", "Response"), lines: bodyLines },
187
187
  ],
188
188
  width,
189
- applyBg: false,
190
189
  },
191
190
  theme,
192
191
  );
@@ -14,7 +14,12 @@ import {
14
14
  import { formatBytes } from "@sayknow-cli/utils";
15
15
  import { theme } from "../../modes/theme/theme";
16
16
  import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
17
- import type { ResumeSessionIdentity, ResumeTailInspection, SessionInfo } from "../../session/session-manager";
17
+ import {
18
+ prioritizeStarredSessions,
19
+ type ResumeSessionIdentity,
20
+ type ResumeTailInspection,
21
+ type SessionInfo,
22
+ } from "../../session/session-manager";
18
23
  import { DynamicBorder } from "./dynamic-border";
19
24
  import { HookSelectorComponent } from "./hook-selector";
20
25
 
@@ -34,6 +39,7 @@ export type SessionInspector = (sessionPath: string) => Promise<ResumeTailInspec
34
39
 
35
40
  type SelectorState =
36
41
  | { kind: "browsing" }
42
+ | { kind: "starring"; token: number }
37
43
  | { kind: "checking"; session: SessionInfo; token: number }
38
44
  | { kind: "confirming"; session: SessionInfo; token: number; identity: ResumeSessionIdentity }
39
45
  | { kind: "settled" };
@@ -47,12 +53,13 @@ class SessionList implements Component {
47
53
  onCancel?: () => void;
48
54
  onExit: () => void = () => {};
49
55
  onDeleteRequest?: (session: SessionInfo) => void;
56
+ onToggleStarRequest?: (session: SessionInfo) => void;
50
57
 
51
58
  constructor(
52
59
  private readonly allSessions: SessionInfo[],
53
60
  private readonly showCwd = false,
54
61
  ) {
55
- this.#filteredSessions = allSessions;
62
+ this.#filteredSessions = prioritizeStarredSessions(allSessions);
56
63
  this.#searchInput.onSubmit = () => {
57
64
  const selected = this.#filteredSessions[this.#selectedIndex];
58
65
  if (selected) this.onSelect?.(selected);
@@ -71,17 +78,19 @@ class SessionList implements Component {
71
78
  }
72
79
 
73
80
  #filterSessions(query: string): void {
74
- this.#filteredSessions = fuzzyFilter(this.allSessions, query, session =>
75
- [
76
- session.id,
77
- session.title ?? "",
78
- session.cwd ?? "",
79
- session.firstMessage ?? "",
80
- session.allMessagesText,
81
- session.path,
82
- ]
83
- .filter(Boolean)
84
- .join(" "),
81
+ this.#filteredSessions = prioritizeStarredSessions(
82
+ fuzzyFilter(this.allSessions, query, session =>
83
+ [
84
+ session.id,
85
+ session.title ?? "",
86
+ session.cwd ?? "",
87
+ session.firstMessage ?? "",
88
+ session.allMessagesText,
89
+ session.path,
90
+ ]
91
+ .filter(Boolean)
92
+ .join(" "),
93
+ ),
85
94
  );
86
95
  this.#clampSelectedIndex();
87
96
  }
@@ -93,6 +102,18 @@ class SessionList implements Component {
93
102
  this.#filterSessions(this.#searchInput.getValue());
94
103
  }
95
104
 
105
+ /** Apply a persisted star change and keep the cursor on the same session as it moves. */
106
+ setSessionStarred(sessionPath: string, starred: boolean): void {
107
+ const session = this.allSessions.find(candidate => candidate.path === sessionPath);
108
+ if (!session) return;
109
+ session.starred = starred;
110
+ this.#filterSessions(this.#searchInput.getValue());
111
+ this.#selectedIndex = Math.max(
112
+ 0,
113
+ this.#filteredSessions.findIndex(candidate => candidate.path === sessionPath),
114
+ );
115
+ }
116
+
96
117
  invalidate(): void {}
97
118
 
98
119
  render(width: number): string[] {
@@ -131,8 +152,9 @@ class SessionList implements Component {
131
152
  const cursorWidth = visibleWidth(cursor);
132
153
  const prefix = selected ? theme.fg("accent", cursor) : padding(cursorWidth);
133
154
  const message = session.firstMessage.replace(/\n/g, " ").trim();
134
- const title = truncateToWidth(session.title ?? message, width - cursorWidth);
135
- lines.push(prefix + (selected ? theme.bold(title) : title));
155
+ const star = session.starred ? theme.fg("warning", `${theme.icon.star} `) : "";
156
+ const title = truncateToWidth(session.title ?? message, width - cursorWidth - visibleWidth(star));
157
+ lines.push(prefix + star + (selected ? theme.bold(title) : title));
136
158
  if (session.title) lines.push(` ${theme.fg("dim", truncateToWidth(message, width - cursorWidth))}`);
137
159
  lines.push(
138
160
  theme.fg(
@@ -147,10 +169,10 @@ class SessionList implements Component {
147
169
  }
148
170
  if (start > 0 || end < this.#filteredSessions.length)
149
171
  lines.push(theme.fg("muted", ` (${this.#selectedIndex + 1}/${this.#filteredSessions.length})`));
150
- lines.push(
151
- "",
152
- theme.fg("muted", " [Del to delete selected transcript/artifacts, Enter to select, Esc to cancel]"),
153
- );
172
+ lines.push("");
173
+ if (this.onToggleStarRequest)
174
+ lines.push(theme.fg("muted", truncateToWidth(" [Ctrl+S to star/unstar the selected session]", width)));
175
+ lines.push(theme.fg("muted", " [Del to delete selected transcript/artifacts, Enter to select, Esc to cancel]"));
154
176
  return lines;
155
177
  }
156
178
 
@@ -169,7 +191,10 @@ class SessionList implements Component {
169
191
  this.#selectedIndex += delta;
170
192
  this.#clampSelectedIndex();
171
193
  };
172
- if (matchesKey(keyData, "delete")) {
194
+ if (matchesKey(keyData, "ctrl+s")) {
195
+ const selected = this.#filteredSessions[this.#selectedIndex];
196
+ if (selected) this.onToggleStarRequest?.(selected);
197
+ } else if (matchesKey(keyData, "delete")) {
173
198
  const selected = this.#filteredSessions[this.#selectedIndex];
174
199
  if (selected) this.onDeleteRequest?.(selected);
175
200
  } else if (matchesKey(keyData, "up")) move(-1);
@@ -186,7 +211,7 @@ class SessionList implements Component {
186
211
  }
187
212
  }
188
213
 
189
- /** A one-shot resume consent selector. It never opens or mutates sessions. */
214
+ /** A one-shot resume consent selector with host-owned deletion and star persistence. It never opens sessions. */
190
215
  export class SessionSelectorComponent extends Container {
191
216
  #sessionList: SessionList;
192
217
  #confirmationDialog: HookSelectorComponent | null = null;
@@ -203,6 +228,7 @@ export class SessionSelectorComponent extends Container {
203
228
  private readonly onDelete?: (session: SessionInfo) => Promise<boolean>,
204
229
  private readonly inspector?: SessionInspector,
205
230
  private readonly onSelection?: (selection: SessionSelectionResult) => void,
231
+ private readonly onSetStarred?: (session: SessionInfo, starred: boolean) => Promise<void>,
206
232
  ) {
207
233
  super();
208
234
  this.addChild(new Spacer(1));
@@ -219,6 +245,7 @@ export class SessionSelectorComponent extends Container {
219
245
  this.#sessionList.onCancel = () => this.#cancel();
220
246
  this.#sessionList.onExit = () => this.#exit();
221
247
  this.#sessionList.onDeleteRequest = session => this.#showDeleteConfirmation(session);
248
+ if (this.onSetStarred) this.#sessionList.onToggleStarRequest = session => void this.#toggleStar(session);
222
249
  this.addChild(this.#sessionList);
223
250
  this.addChild(new Spacer(1));
224
251
  this.addChild(new DynamicBorder());
@@ -263,6 +290,29 @@ export class SessionSelectorComponent extends Container {
263
290
  this.#sessionList.setInputFrozen(true);
264
291
  this.onExit();
265
292
  }
293
+ async #toggleStar(session: SessionInfo): Promise<void> {
294
+ if (!this.onSetStarred || this.#state.kind !== "browsing" || this.#confirmationDialog) return;
295
+ const token = ++this.#nextToken;
296
+ const starred = !session.starred;
297
+ this.#state = { kind: "starring", token };
298
+ this.#sessionList.setInputFrozen(true);
299
+ this.#clearError();
300
+ this.#requestRender();
301
+ try {
302
+ await this.onSetStarred(session, starred);
303
+ if (this.#state.kind !== "starring" || this.#state.token !== token) return;
304
+ this.#sessionList.setSessionStarred(session.path, starred);
305
+ } catch (error) {
306
+ if (this.#state.kind !== "starring" || this.#state.token !== token) return;
307
+ this.#showError(error instanceof Error ? error.message : String(error));
308
+ } finally {
309
+ if (this.#state.kind === "starring" && this.#state.token === token) {
310
+ this.#state = { kind: "browsing" };
311
+ this.#sessionList.setInputFrozen(false);
312
+ this.#requestRender();
313
+ }
314
+ }
315
+ }
266
316
  async #inspect(session: SessionInfo): Promise<void> {
267
317
  const inspector = this.inspector;
268
318
  if (!inspector || this.#state.kind !== "browsing") return;
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
- import { type Component, Container, matchesKey, truncateToWidth } from "@sayknow-cli/tui";
3
- import type { SessionInfo } from "../../session/session-manager";
2
+ import { type Component, Container, matchesKey, truncateToWidth, visibleWidth } from "@sayknow-cli/tui";
3
+ import { prioritizeStarredSessions, type SessionInfo } from "../../session/session-manager";
4
4
  import { theme } from "../theme/theme";
5
5
 
6
6
  const PRESENCE_MAX_BYTES = 4096;
@@ -41,6 +41,7 @@ export interface DashboardSession {
41
41
  id: string;
42
42
  cwd: string;
43
43
  title: string;
44
+ starred: boolean;
44
45
  modified: Date;
45
46
  messageCount: number;
46
47
  messageCountIsEstimate?: boolean;
@@ -78,10 +79,11 @@ export function dashboardSessions(
78
79
  sessions: readonly SessionInfo[],
79
80
  options: { readFile?: (filePath: string) => string; now?: number } = {},
80
81
  ): DashboardSession[] {
81
- return sessions.map(session => ({
82
+ return prioritizeStarredSessions(sessions).map(session => ({
82
83
  id: session.id,
83
84
  cwd: session.cwd,
84
85
  title: session.title ?? session.firstMessage,
86
+ starred: session.starred,
85
87
  modified: session.modified,
86
88
  messageCountIsEstimate: session.messageCountIsEstimate,
87
89
  messageCount: session.messageCount,
@@ -169,10 +171,11 @@ class SessionDashboardRow implements Component {
169
171
  const fullMetadata = `· ${count} · ${modified}`;
170
172
  const compactMetadata = `· ${this.session.messageCountIsEstimate ? `~${this.session.messageCount}` : this.session.messageCount} msgs`;
171
173
  const metadata = width >= 48 ? fullMetadata : width >= 24 ? compactMetadata : "";
172
- const titleWidth = Math.max(0, width - 2 - metadata.length - (metadata ? 1 : 0));
174
+ const star = this.session.starred ? theme.fg("warning", `${theme.icon.star} `) : "";
175
+ const titleWidth = Math.max(0, width - 2 - visibleWidth(star) - metadata.length - (metadata ? 1 : 0));
173
176
  return [
174
177
  fit(
175
- ` ${status} ${truncateToWidth(title, titleWidth)}${metadata ? ` ${theme.fg("dim", metadata)}` : ""}`,
178
+ ` ${status} ${star}${truncateToWidth(title, titleWidth)}${metadata ? ` ${theme.fg("dim", metadata)}` : ""}`,
176
179
  width,
177
180
  ),
178
181
  fit(` ${theme.fg("dim", cwd)}`, width),
@@ -379,6 +379,7 @@ class StatusLineCustomEditor extends Container {
379
379
  "Status Line Separator",
380
380
  "Style of separators between segments.",
381
381
  [
382
+ { value: "dot", label: "Dot" },
382
383
  { value: "powerline", label: "Powerline" },
383
384
  { value: "powerline-thin", label: "Thin chevron" },
384
385
  { value: "slash", label: "Slash" },
@@ -4,7 +4,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
4
4
  default: {
5
5
  leftSegments: ["model", "mode", "git", "pr", "path"],
6
6
  rightSegments: ["session_name", "jobs", "token_rate", "cost"],
7
- separator: "slash",
7
+ separator: "dot",
8
8
  segmentOptions: {
9
9
  model: { showThinkingLevel: true },
10
10
  path: { abbreviate: true, maxLength: 32, stripWorkPrefix: true },
@@ -15,7 +15,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
15
15
  "default-usage": {
16
16
  leftSegments: ["model", "mode", "git", "pr", "path"],
17
17
  rightSegments: ["session_name", "jobs", "token_rate", "usage", "cost"],
18
- separator: "slash",
18
+ separator: "dot",
19
19
  segmentOptions: {
20
20
  model: { showThinkingLevel: true },
21
21
  path: { abbreviate: true, maxLength: 32, stripWorkPrefix: true },
@@ -26,7 +26,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
26
26
  minimal: {
27
27
  leftSegments: ["path", "git"],
28
28
  rightSegments: ["session_name", "jobs", "mode", "context_pct"],
29
- separator: "slash",
29
+ separator: "dot",
30
30
  segmentOptions: {
31
31
  path: { abbreviate: true, maxLength: 30 },
32
32
  git: { showBranch: true, showStaged: false, showUnstaged: false, showUntracked: false },
@@ -36,7 +36,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
36
36
  compact: {
37
37
  leftSegments: ["model", "mode", "git", "pr"],
38
38
  rightSegments: ["session_name", "jobs", "cost"],
39
- separator: "slash",
39
+ separator: "dot",
40
40
  segmentOptions: {
41
41
  model: { showThinkingLevel: false },
42
42
  git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: false },
@@ -106,7 +106,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
106
106
  // User-defined - these are just defaults that get overridden
107
107
  leftSegments: ["model", "mode", "path", "git", "pr"],
108
108
  rightSegments: ["session_name", "jobs", "token_total", "cost"],
109
- separator: "slash",
109
+ separator: "dot",
110
110
  segmentOptions: {},
111
111
  },
112
112
  };
@@ -27,6 +27,10 @@ export function getSeparator(style: StatusLineSeparatorStyle, theme: Theme): Sep
27
27
  useBgAsFg: true,
28
28
  },
29
29
  };
30
+ case "dot": {
31
+ const dot = trimSep(theme.sep.dot);
32
+ return { left: dot, right: dot };
33
+ }
30
34
  case "slash": {
31
35
  const slash = trimSep(theme.sep.slash);
32
36
  return { left: slash, right: slash };
@@ -289,8 +289,10 @@ export class ToolExecutionComponent extends Container {
289
289
  // Always create both - contentBox for custom tools/bash/tools with renderers, contentText for other built-ins.
290
290
  // Vertical padding is 0: block separation comes solely from the leading Spacer
291
291
  // (1 blank line above each block), matching reference TUIs (083.2).
292
- this.#contentBox = new Box(1, 0, (text: string) => theme.bg("toolPendingBg", text));
293
- this.#contentText = new Text("", 1, 0, (text: string) => theme.bg("toolPendingBg", text));
292
+ // Tool blocks sit on the transcript background; state is carried by the rail and
293
+ // status glyph (see renderOutputBlock), never by a filled card.
294
+ this.#contentBox = new Box(1, 0);
295
+ this.#contentText = new Text("", 1, 0);
294
296
 
295
297
  // Use Box for custom tools or built-in tools that have renderers
296
298
  const hasRenderer = toolName in toolRenderers;
@@ -607,13 +609,6 @@ export class ToolExecutionComponent extends Container {
607
609
  }
608
610
 
609
611
  #updateDisplay(): void {
610
- // Set background based on state
611
- const bgFn = this.#isPartial
612
- ? (text: string) => theme.bg("toolPendingBg", text)
613
- : this.#result?.isError
614
- ? (text: string) => theme.bg("toolErrorBg", text)
615
- : (text: string) => theme.bg("toolSuccessBg", text);
616
-
617
612
  // Sync shared mutable render state for component closures
618
613
  this.#renderState.expanded = this.#expanded;
619
614
  this.#renderState.isPartial = this.#isPartial;
@@ -624,8 +619,6 @@ export class ToolExecutionComponent extends Container {
624
619
  const tool = this.#tool;
625
620
  const mergeCallAndResult = Boolean((tool as { mergeCallAndResult?: boolean }).mergeCallAndResult);
626
621
  // Custom tools use Box for flexible component rendering
627
- const inline = Boolean((tool as { inline?: boolean }).inline);
628
- this.#contentBox.setBgFn(inline ? undefined : bgFn);
629
622
  this.#contentBox.clear();
630
623
 
631
624
  // Render call component
@@ -699,7 +692,6 @@ export class ToolExecutionComponent extends Container {
699
692
  | undefined;
700
693
  if (perFileResults && perFileResults.length > 1) {
701
694
  // Multi-file: render each file as its own Box (identical to separate tool calls)
702
- this.#contentBox.setBgFn(undefined);
703
695
  this.#contentBox.clear();
704
696
 
705
697
  const renderContext = this.#buildRenderContext();
@@ -712,10 +704,7 @@ export class ToolExecutionComponent extends Container {
712
704
  this.#multiFileBoxes.push(spacer);
713
705
  this.addChild(spacer);
714
706
  }
715
- const fileBgFn = fileResult.isError
716
- ? (text: string) => theme.bg("toolErrorBg", text)
717
- : (text: string) => theme.bg("toolSuccessBg", text);
718
- const fileBox = new Box(1, 0, fileBgFn);
707
+ const fileBox = new Box(1, 0);
719
708
  try {
720
709
  const resultComponent = renderer.renderResult(
721
710
  { content: [], details: fileResult, isError: fileResult.isError },
@@ -741,7 +730,7 @@ export class ToolExecutionComponent extends Container {
741
730
  const pendingSpacer = new Spacer(1);
742
731
  this.#multiFileBoxes.push(pendingSpacer);
743
732
  this.addChild(pendingSpacer);
744
- const pendingBox = new Box(1, 0, (text: string) => theme.bg("toolPendingBg", text));
733
+ const pendingBox = new Box(1, 0);
745
734
  const pendingText = renderStatusLine(
746
735
  {
747
736
  icon: "pending",
@@ -757,7 +746,6 @@ export class ToolExecutionComponent extends Container {
757
746
  } else {
758
747
  // Single-file or no result: standard rendering
759
748
  // Inline renderers skip background styling
760
- this.#contentBox.setBgFn(renderer.inline ? undefined : bgFn);
761
749
  this.#contentBox.clear();
762
750
 
763
751
  const renderContext = this.#buildRenderContext();
@@ -806,7 +794,6 @@ export class ToolExecutionComponent extends Container {
806
794
  }
807
795
  } else {
808
796
  // Other built-in tools: use Text directly with caching
809
- this.#contentText.setCustomBgFn(bgFn);
810
797
  this.#contentText.setText(this.#formatToolExecution());
811
798
  }
812
799
 
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import { resolveOAuthStorageProvider } from "@sayknow-cli/ai";
2
3
 
3
4
  import { type Component, truncateToWidth, visibleWidth } from "@sayknow-cli/tui";
4
5
  import { formatCount, getProjectDir } from "@sayknow-cli/utils";
@@ -33,6 +34,33 @@ import { getSeparator } from "./status-line/separators";
33
34
  import { calculateTokensPerSecond } from "./status-line/token-rate";
34
35
  import type { SeparatorDef } from "./status-line/types";
35
36
 
37
+ function usageReportAccountId(report: unknown): string | undefined {
38
+ if (!report || typeof report !== "object") return undefined;
39
+ const record = report as {
40
+ metadata?: unknown;
41
+ limits?: unknown;
42
+ };
43
+ const metadata = record.metadata;
44
+ const rawMetadataAccountId =
45
+ metadata && typeof metadata === "object" ? (metadata as { accountId?: unknown }).accountId : undefined;
46
+ const metadataAccountId =
47
+ typeof rawMetadataAccountId === "string" && rawMetadataAccountId.trim() ? rawMetadataAccountId.trim() : undefined;
48
+ if (!Array.isArray(record.limits)) return metadataAccountId;
49
+
50
+ const scopeAccountIds = new Set<string>();
51
+ for (const limit of record.limits) {
52
+ if (!limit || typeof limit !== "object") continue;
53
+ const scope = (limit as { scope?: unknown }).scope;
54
+ if (!scope || typeof scope !== "object") continue;
55
+ const accountId = (scope as { accountId?: unknown }).accountId;
56
+ if (typeof accountId === "string" && accountId.trim()) scopeAccountIds.add(accountId.trim());
57
+ }
58
+ if (scopeAccountIds.size > 1) return undefined;
59
+ const [scopeAccountId] = scopeAccountIds;
60
+ if (metadataAccountId && scopeAccountId && metadataAccountId !== scopeAccountId) return undefined;
61
+ return metadataAccountId ?? scopeAccountId;
62
+ }
63
+
36
64
  export interface StatusLineSegmentOptions {
37
65
  model?: { showThinkingLevel?: boolean; showContextPercent?: boolean };
38
66
  path?: { abbreviate?: boolean; maxLength?: number; stripWorkPrefix?: boolean };
@@ -192,6 +220,9 @@ export class StatusLineComponent implements Component {
192
220
 
193
221
  // Provider usage caching (5-min TTL, OAuth/sub only)
194
222
  #cachedUsage: SegmentContext["usage"] = null;
223
+ #cachedUsageReports: unknown = null;
224
+ #cachedUsageProvider: string | undefined;
225
+ #cachedUsageAccountId: string | undefined;
195
226
  #usageFetchedAt = 0;
196
227
  #usageInFlight = false;
197
228
 
@@ -507,7 +538,14 @@ export class StatusLineComponent implements Component {
507
538
  void fetcher
508
539
  .call(this.session)
509
540
  .then(reports => {
510
- this.#cachedUsage = this.#normalizeUsageReports(reports);
541
+ this.#cachedUsageReports = reports;
542
+ this.#cachedUsageProvider = this.#activeUsageProvider();
543
+ this.#cachedUsageAccountId = this.#activeCodexAccountId(this.#cachedUsageProvider);
544
+ this.#cachedUsage = this.#normalizeUsageReports(
545
+ reports,
546
+ this.#cachedUsageProvider,
547
+ this.#cachedUsageAccountId,
548
+ );
511
549
  this.#usageFetchedAt = Date.now();
512
550
  if (this.#onBranchChange) {
513
551
  this.#onBranchChange();
@@ -525,8 +563,25 @@ export class StatusLineComponent implements Component {
525
563
  });
526
564
  }
527
565
 
528
- #normalizeUsageReports(reports: unknown): SegmentContext["usage"] {
529
- if (!Array.isArray(reports)) return null;
566
+ #normalizeUsageReports(
567
+ reports: unknown,
568
+ activeProvider = this.#activeUsageProvider(),
569
+ activeCodexAccountId = this.#activeCodexAccountId(activeProvider),
570
+ ): SegmentContext["usage"] {
571
+ if (!activeProvider || !Array.isArray(reports)) return null;
572
+ const activeCodexReportCount =
573
+ activeProvider === "openai-codex" && activeCodexAccountId
574
+ ? reports.filter(report => {
575
+ if (!report || typeof report !== "object") return false;
576
+ const provider = (report as { provider?: unknown }).provider;
577
+ return (
578
+ typeof provider === "string" &&
579
+ resolveOAuthStorageProvider(provider) === activeProvider &&
580
+ usageReportAccountId(report) === activeCodexAccountId
581
+ );
582
+ }).length
583
+ : 0;
584
+ const ambiguousActiveCodexReports = activeCodexReportCount > 1;
530
585
  const windows: NonNullable<SegmentContext["usage"]>["windows"] = [];
531
586
  const seen = new Set<string>();
532
587
  const now = Date.now();
@@ -560,14 +615,23 @@ export class StatusLineComponent implements Component {
560
615
  for (const report of reports) {
561
616
  if (!report || typeof report !== "object") continue;
562
617
  const provider = (report as { provider?: unknown }).provider;
563
- const providerId = typeof provider === "string" ? provider : undefined;
618
+ const providerId = typeof provider === "string" ? resolveOAuthStorageProvider(provider) : undefined;
619
+ if (providerId !== activeProvider) continue;
620
+ // Codex usage fetches cover every OAuth account; fail closed unless this session's account matches uniquely.
621
+ if (
622
+ providerId === "openai-codex" &&
623
+ (!activeCodexAccountId ||
624
+ ambiguousActiveCodexReports ||
625
+ usageReportAccountId(report) !== activeCodexAccountId)
626
+ )
627
+ continue;
564
628
  const limits = (report as { limits?: unknown }).limits;
565
629
  if (!Array.isArray(limits)) continue;
566
630
  for (const limit of limits) {
567
631
  if (!limit || typeof limit !== "object") continue;
568
632
  const l = limit as {
569
633
  id?: unknown;
570
- scope?: { windowId?: string; tier?: string; modelId?: string };
634
+ scope?: { provider?: unknown; windowId?: string; tier?: string; modelId?: string };
571
635
  window?: { id?: string; resetsAt?: number };
572
636
  amount?: { usedFraction?: number };
573
637
  };
@@ -578,6 +642,10 @@ export class StatusLineComponent implements Component {
578
642
  const tier = l.scope?.tier;
579
643
  const modelId = l.scope?.modelId;
580
644
  const resetsAt = l.window?.resetsAt;
645
+ // A limit scoped to another provider never describes the active one.
646
+ const scopeProvider =
647
+ typeof l.scope?.provider === "string" ? resolveOAuthStorageProvider(l.scope.provider) : undefined;
648
+ if (scopeProvider !== undefined && scopeProvider !== providerId) continue;
581
649
 
582
650
  if (providerId === "openai-codex") {
583
651
  if (id === "openai-codex:primary" || (!id && !!windowId && windowId !== "7d" && !modelId)) {
@@ -589,7 +657,8 @@ export class StatusLineComponent implements Component {
589
657
  }
590
658
  } else if (windowId === "5h" && !tier) {
591
659
  pushWindow(`${providerId ?? "provider"}:5h`, "5h", fraction, resetsAt, "m");
592
- } else if (windowId === "7d" && !tier) {
660
+ } else if (windowId === "7d" && !tier && providerId !== "grok-build" && providerId !== "xai") {
661
+ // Grok's only report is SuperGrok monthly credits filed under "7d"; a month is not a 7d quota.
593
662
  pushWindow(`${providerId ?? "provider"}:7d`, "7d", fraction, resetsAt, "h");
594
663
  }
595
664
  }
@@ -598,6 +667,28 @@ export class StatusLineComponent implements Component {
598
667
  return windows.length > 0 ? { windows } : null;
599
668
  }
600
669
 
670
+ #activeUsageProvider(): string | undefined {
671
+ const model = this.session.state.model ?? this.session.model;
672
+ if (!model || typeof model !== "object") return undefined;
673
+ const provider = (model as { provider?: unknown }).provider;
674
+ return typeof provider === "string" && provider.length > 0 ? resolveOAuthStorageProvider(provider) : undefined;
675
+ }
676
+
677
+ #activeCodexAccountId(activeProvider: string | undefined): string | undefined {
678
+ if (activeProvider !== "openai-codex") return undefined;
679
+ // Turns resolve their API key with the session id as the sticky key, so the account follows it too.
680
+ return this.session.modelRegistry.authStorage.getOAuthAccountId(activeProvider, this.session.sessionId);
681
+ }
682
+
683
+ #syncCachedUsageForActiveProvider(): void {
684
+ const activeProvider = this.#activeUsageProvider();
685
+ const activeCodexAccountId = this.#activeCodexAccountId(activeProvider);
686
+ if (this.#cachedUsageProvider === activeProvider && this.#cachedUsageAccountId === activeCodexAccountId) return;
687
+ this.#cachedUsageProvider = activeProvider;
688
+ this.#cachedUsageAccountId = activeCodexAccountId;
689
+ this.#cachedUsage = this.#normalizeUsageReports(this.#cachedUsageReports, activeProvider, activeCodexAccountId);
690
+ }
691
+
601
692
  #buildSegmentContext(
602
693
  width: number,
603
694
  effectiveSettings: Required<
@@ -607,7 +698,8 @@ export class StatusLineComponent implements Component {
607
698
  ): SegmentContext {
608
699
  const state = this.session.state;
609
700
 
610
- // Trigger background fetch (5-min TTL); render uses cached value
701
+ // Reproject cached reports when the model or Codex account changed, then refresh (5-min TTL).
702
+ this.#syncCachedUsageForActiveProvider();
611
703
  this.refreshUsageInBackground();
612
704
 
613
705
  // Get usage statistics
@@ -777,11 +869,10 @@ export class StatusLineComponent implements Component {
777
869
  const ctx = this.#buildSegmentContext(width, effectiveSettings);
778
870
  const separatorDef = getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
779
871
 
780
- // Use the subtle surface tone (the same elevated background as user-message
781
- // bubbles) instead of the heavy `statusLineBg` block, so the rail layers
782
- // just above the base background as a quiet zone rather than a solid bar.
783
- // Resolving through a semantic slot keeps it correct across every theme.
784
- const bgAnsi = theme.getBgAnsi("userMessageBg");
872
+ // Powerline separators paint their arrows from a surface tone; every other
873
+ // separator leaves the rail on the bare terminal so it reads as a ledger line,
874
+ // not a bar.
875
+ const bgAnsi = separatorDef.endCaps ? theme.getBgAnsi("statusLineBg") : "";
785
876
  const fgAnsi = theme.getFgAnsi("text");
786
877
  const sepAnsi = theme.getFgAnsi("statusLineSep");
787
878