@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.
- package/CHANGELOG.md +25 -1
- package/README.md +2 -2
- package/dist/types/config/keybindings.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +7 -3
- package/dist/types/i18n/messages/en.d.ts +16 -0
- package/dist/types/modes/components/session-selector.d.ts +7 -3
- package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
- package/dist/types/modes/components/user-message-selector.d.ts +5 -2
- package/dist/types/modes/components/user-message.d.ts +3 -1
- package/dist/types/modes/components/welcome.d.ts +49 -6
- package/dist/types/modes/controllers/command-controller.d.ts +1 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +5 -0
- package/dist/types/modes/interactive-mode.d.ts +7 -1
- package/dist/types/modes/theme/defaults/index.d.ts +342 -0
- package/dist/types/modes/theme/theme.d.ts +5 -1
- package/dist/types/modes/types.d.ts +1 -1
- package/dist/types/session/session-manager.d.ts +9 -0
- package/dist/types/session/session-stars.d.ts +15 -0
- package/dist/types/tui/output-block.d.ts +12 -1
- package/dist/types/tui/utils.d.ts +1 -3
- package/dist/types/utils/session-title-generation.d.ts +6 -0
- package/dist/types/utils/title-generator.d.ts +11 -2
- package/package.json +7 -7
- package/scripts/generate-sdk-operation-inventory.ts +8 -0
- package/src/cli/session-picker.ts +4 -0
- package/src/config/keybindings.ts +1 -1
- package/src/config/settings-schema.ts +4 -3
- package/src/i18n/messages/de.messages.ts +4 -1
- package/src/i18n/messages/de.settings.ts +2 -0
- package/src/i18n/messages/de.ts +16 -0
- package/src/i18n/messages/en.ts +16 -0
- package/src/i18n/messages/es.messages.ts +4 -1
- package/src/i18n/messages/es.settings.ts +2 -0
- package/src/i18n/messages/es.ts +16 -0
- package/src/i18n/messages/fr.messages.ts +4 -1
- package/src/i18n/messages/fr.settings.ts +2 -0
- package/src/i18n/messages/fr.ts +16 -0
- package/src/i18n/messages/ja.messages.ts +4 -1
- package/src/i18n/messages/ja.settings.ts +2 -0
- package/src/i18n/messages/ja.ts +16 -0
- package/src/i18n/messages/ko.messages.ts +4 -1
- package/src/i18n/messages/ko.settings.ts +2 -0
- package/src/i18n/messages/ko.ts +16 -0
- package/src/i18n/messages/zh.messages.ts +2 -1
- package/src/i18n/messages/zh.settings.ts +2 -0
- package/src/i18n/messages/zh.ts +16 -0
- package/src/internal-urls/docs-index.generated.ts +10 -10
- package/src/lsp/render.ts +0 -1
- package/src/modes/components/session-selector.ts +71 -21
- package/src/modes/components/sessions-dashboard.ts +8 -5
- package/src/modes/components/settings-selector.ts +1 -0
- package/src/modes/components/status-line/presets.ts +5 -5
- package/src/modes/components/status-line/separators.ts +4 -0
- package/src/modes/components/tool-execution.ts +6 -19
- package/src/modes/components/tool-status-header.ts +103 -12
- package/src/modes/components/user-message-selector.ts +57 -15
- package/src/modes/components/user-message.ts +25 -15
- package/src/modes/components/welcome.ts +374 -478
- package/src/modes/controllers/command-controller.ts +56 -7
- package/src/modes/controllers/input-controller.ts +4 -0
- package/src/modes/controllers/selector-controller.ts +91 -10
- package/src/modes/interactive-mode.ts +101 -12
- package/src/modes/theme/defaults/glow-octopus.json +114 -0
- package/src/modes/theme/defaults/index.ts +6 -0
- package/src/modes/theme/defaults/ink-octopus.json +114 -0
- package/src/modes/theme/defaults/violet-octopus.json +114 -0
- package/src/modes/theme/theme.ts +18 -2
- package/src/modes/types.ts +1 -1
- package/src/prompts/system/title-system.md +1 -1
- package/src/sdk/protocol/operation-inventory.generated.json +40 -0
- package/src/session/agent-session.ts +12 -0
- package/src/session/internal/managed-session-scope.ts +10 -0
- package/src/session/session-manager.ts +26 -1
- package/src/session/session-stars.ts +104 -0
- package/src/slash-commands/builtin-registry.ts +104 -4
- package/src/tools/debug.ts +0 -1
- package/src/tools/fetch.ts +0 -1
- package/src/tui/output-block.ts +29 -61
- package/src/tui/utils.ts +1 -8
- package/src/utils/session-title-generation.ts +29 -0
- package/src/utils/title-generator.ts +46 -2
package/src/lsp/render.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
135
|
-
|
|
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", " [
|
|
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, "
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
293
|
-
|
|
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
|
|
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
|
|
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.#
|
|
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(
|
|
529
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
781
|
-
//
|
|
782
|
-
//
|
|
783
|
-
|
|
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
|
|