atom-agent 1.2.0 → 1.4.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.
- package/CHANGELOG.md +97 -0
- package/README.md +13 -4
- package/atom.example.json +11 -0
- package/dist/App.js +923 -200
- package/dist/adapters.js +82 -13
- package/dist/agent/goal-evaluator.js +69 -0
- package/dist/agent/loop.js +517 -76
- package/dist/cli.js +11 -3
- package/dist/compact.js +41 -15
- package/dist/config.js +43 -7
- package/dist/context-manager.js +16 -198
- package/dist/context-windows.js +4 -2
- package/dist/env-block.js +5 -5
- package/dist/extension-commands.js +196 -0
- package/dist/extension-ui.js +153 -0
- package/dist/extensions.js +1571 -0
- package/dist/goal.js +583 -0
- package/dist/project-trust.js +96 -0
- package/dist/providers.js +6 -6
- package/dist/scheduler.js +74 -36
- package/dist/session.js +23 -5
- package/dist/sessions.js +25 -6
- package/dist/telemetry-dashboard.js +28 -0
- package/dist/telemetry.js +39 -0
- package/dist/tools/compaction-hooks.js +165 -0
- package/dist/tools/custom.js +189 -0
- package/dist/tools/intercept.js +145 -0
- package/dist/tools/overrides.js +105 -0
- package/dist/tools/provider-hooks.js +224 -0
- package/dist/tools/registry.js +246 -17
- package/dist/tools.js +44 -0
- package/dist/ui/diff-panel.js +5 -5
- package/dist/ui/diff-view.js +3 -2
- package/dist/ui/diff.js +7 -52
- package/dist/ui/modals.js +5 -5
- package/dist/ui/palette.js +1 -1
- package/dist/ui/side-by-side.js +7 -5
- package/dist/ui/status-bar.js +80 -5
- package/dist/ui/transcript.js +3 -3
- package/dist/zen.js +305 -75
- package/documentation/architecture.md +114 -0
- package/documentation/cli.md +82 -0
- package/documentation/compaction.md +50 -0
- package/documentation/configuration.md +111 -0
- package/documentation/development.md +62 -0
- package/documentation/extensions.md +160 -0
- package/documentation/getting-started.md +63 -0
- package/documentation/goals.md +41 -0
- package/documentation/index.md +41 -0
- package/documentation/observability.md +70 -0
- package/documentation/permissions.md +66 -0
- package/documentation/providers.md +78 -0
- package/documentation/sessions.md +92 -0
- package/documentation/skills.md +57 -0
- package/documentation/tools.md +94 -0
- package/documentation/troubleshooting.md +54 -0
- package/examples/extensions/01-audit-gate.js +24 -0
- package/examples/extensions/02-notes-tool.js +32 -0
- package/examples/extensions/03-custom-command.js +32 -0
- package/package.json +6 -2
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Extension slash commands (ticket 04): the runtime store behind
|
|
2
|
+
// ExtensionAPI.registerCommand. Dependency-free (no imports) like
|
|
3
|
+
// tools/custom.ts and tools/intercept.ts, so the extension host and the App
|
|
4
|
+
// slash dispatch share it with no cycle: extensions register here, the App
|
|
5
|
+
// lists/parses/runs here, nobody imports the other.
|
|
6
|
+
//
|
|
7
|
+
// A command is a real slash command (`/name args...`): it appears in the
|
|
8
|
+
// command palette and the "/" menu, takes typed free-text arguments, and
|
|
9
|
+
// runs extension code OUTSIDE the model turn loop with a generation-bound
|
|
10
|
+
// context (prompt the user, read a session snapshot, post messages).
|
|
11
|
+
//
|
|
12
|
+
// Semantics:
|
|
13
|
+
// - Names are bare in the definition ("deploy") and slash-prefixed at the
|
|
14
|
+
// seam ("/deploy"). Lowercase [a-z0-9_-] only, mirroring builtin style so
|
|
15
|
+
// menu/palette matching stays exact and case-free.
|
|
16
|
+
// - Collision decision: builtins always win. The host rejects a colliding
|
|
17
|
+
// name at activation (loud error, nothing commits) and App dispatch
|
|
18
|
+
// routes builtins first as backstop — a builtin is never shadowed, and
|
|
19
|
+
// there is no renamed form to discover. Duplicate extension names throw
|
|
20
|
+
// the same way (first registration wins, deterministically by load order).
|
|
21
|
+
// - Handlers never touch model history: say() stages messages and the
|
|
22
|
+
// runner commits them only on success. A throwing handler drops the
|
|
23
|
+
// stage and surfaces a clean `Error:` string — the session is untouched.
|
|
24
|
+
// - The context is generation-bound: every ctx call runs checkStale first,
|
|
25
|
+
// so use after a session replacement throws loudly instead of acting on
|
|
26
|
+
// the wrong session (same rule as the event API).
|
|
27
|
+
const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
28
|
+
const store = new Map();
|
|
29
|
+
function isRecord(value) {
|
|
30
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
function errorText(e) {
|
|
33
|
+
return e instanceof Error ? e.message : String(e ?? "unknown error");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Validate a registration shape. Throws Error on any problem (bad name,
|
|
37
|
+
* empty description, non-function handler). Duplicate and builtin-
|
|
38
|
+
* collision checks live with the callers that own those names (the store
|
|
39
|
+
* owns extension names; the host owns the builtin set).
|
|
40
|
+
*/
|
|
41
|
+
export function validateExtensionCommandDef(def) {
|
|
42
|
+
if (!isRecord(def))
|
|
43
|
+
throw new Error("extension command definition must be an object");
|
|
44
|
+
if (typeof def.name !== "string" || !NAME_RE.test(def.name)) {
|
|
45
|
+
throw new Error(`extension command has an invalid name ${JSON.stringify(def.name)} (want a bare 1-64 char a-z0-9_- name, no leading slash)`);
|
|
46
|
+
}
|
|
47
|
+
if (typeof def.description !== "string" || def.description.trim().length === 0) {
|
|
48
|
+
throw new Error(`extension command "/${def.name}" needs a non-empty description`);
|
|
49
|
+
}
|
|
50
|
+
if (typeof def.handler !== "function") {
|
|
51
|
+
throw new Error(`extension command "/${def.name}" needs a handler function`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Register a validated command. Throws on duplicate names. Returns an unregister function. */
|
|
55
|
+
export function registerExtensionCommand(def, owner = "(unknown)") {
|
|
56
|
+
validateExtensionCommandDef(def);
|
|
57
|
+
if (store.has(def.name)) {
|
|
58
|
+
throw new Error(`extension command "/${def.name}" is already registered`);
|
|
59
|
+
}
|
|
60
|
+
const rec = {
|
|
61
|
+
name: def.name,
|
|
62
|
+
description: def.description,
|
|
63
|
+
handler: def.handler,
|
|
64
|
+
owner,
|
|
65
|
+
};
|
|
66
|
+
store.set(def.name, rec);
|
|
67
|
+
let live = true;
|
|
68
|
+
return () => {
|
|
69
|
+
if (!live)
|
|
70
|
+
return;
|
|
71
|
+
live = false;
|
|
72
|
+
if (store.get(def.name) === rec)
|
|
73
|
+
store.delete(def.name);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export function unregisterExtensionCommand(name) {
|
|
77
|
+
return store.delete(name);
|
|
78
|
+
}
|
|
79
|
+
export function getExtensionCommand(name) {
|
|
80
|
+
return store.get(name);
|
|
81
|
+
}
|
|
82
|
+
/** Live commands in registration order (deterministic menu/palette order). */
|
|
83
|
+
export function listExtensionCommands() {
|
|
84
|
+
return [...store.values()];
|
|
85
|
+
}
|
|
86
|
+
/** Test seam: drop every extension command. */
|
|
87
|
+
export function clearExtensionCommands() {
|
|
88
|
+
store.clear();
|
|
89
|
+
}
|
|
90
|
+
// Split "/name args..." into its bare name and raw args. Returns null for
|
|
91
|
+
// anything that is not a single-line slash invocation (plain text, a bare
|
|
92
|
+
// "/", multiline, namespaced skill forms like "/skill:dep" — the name must
|
|
93
|
+
// be followed by whitespace or end, so skill routing is never disturbed).
|
|
94
|
+
export function parseExtensionCommandInput(text) {
|
|
95
|
+
if (!text.startsWith("/") || text.length < 2)
|
|
96
|
+
return null;
|
|
97
|
+
if (/[\r\n]/.test(text))
|
|
98
|
+
return null;
|
|
99
|
+
const match = /^\/([A-Za-z0-9_-]+)(?:[ \t]+([\s\S]*))?$/.exec(text);
|
|
100
|
+
if (!match)
|
|
101
|
+
return null;
|
|
102
|
+
return { name: match[1], args: (match[2] ?? "").trim() };
|
|
103
|
+
}
|
|
104
|
+
// Whitespace tokenizer for ctx.argv: double/single quotes group words, the
|
|
105
|
+
// quotes are stripped, no escape processing (documented, not shell).
|
|
106
|
+
export function splitCommandArgs(args) {
|
|
107
|
+
const out = [];
|
|
108
|
+
let cur = "";
|
|
109
|
+
let quote = null;
|
|
110
|
+
let has = false;
|
|
111
|
+
for (let i = 0; i < args.length; i++) {
|
|
112
|
+
const ch = args[i];
|
|
113
|
+
if (quote) {
|
|
114
|
+
if (ch === quote) {
|
|
115
|
+
quote = null;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
cur += ch;
|
|
119
|
+
}
|
|
120
|
+
has = true;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (ch === '"' || ch === "'") {
|
|
124
|
+
quote = ch;
|
|
125
|
+
has = true;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (ch === " " || ch === "\t") {
|
|
129
|
+
if (has) {
|
|
130
|
+
out.push(cur);
|
|
131
|
+
cur = "";
|
|
132
|
+
has = false;
|
|
133
|
+
}
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
cur += ch;
|
|
137
|
+
has = true;
|
|
138
|
+
}
|
|
139
|
+
if (has)
|
|
140
|
+
out.push(cur);
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
// Run a registered command outside the model turn loop. Never throws for
|
|
144
|
+
// handler failures: unknown names and throwing handlers return a clean
|
|
145
|
+
// `Error:` result. say() output stages in a buffer and commits through
|
|
146
|
+
// deps.say only on success, so a throwing handler leaves the session
|
|
147
|
+
// untouched. Every context call runs checkStale first — stale use throws
|
|
148
|
+
// into the same clean-error path instead of acting on the wrong session.
|
|
149
|
+
export async function runExtensionCommand(name, rawArgs, deps) {
|
|
150
|
+
const rec = store.get(name);
|
|
151
|
+
if (!rec) {
|
|
152
|
+
return { ok: false, error: `Error: unknown extension command "/${name}" (not registered)` };
|
|
153
|
+
}
|
|
154
|
+
const stale = deps.checkStale ?? (() => undefined);
|
|
155
|
+
const staged = [];
|
|
156
|
+
const ctx = {
|
|
157
|
+
name,
|
|
158
|
+
args: rawArgs,
|
|
159
|
+
argv: splitCommandArgs(rawArgs),
|
|
160
|
+
cwd: deps.cwd ?? process.cwd(),
|
|
161
|
+
askUser: async (question, options, allowCustom) => {
|
|
162
|
+
stale();
|
|
163
|
+
return deps.askUser(question, options, allowCustom);
|
|
164
|
+
},
|
|
165
|
+
getSession: () => {
|
|
166
|
+
stale();
|
|
167
|
+
return deps.getSession();
|
|
168
|
+
},
|
|
169
|
+
say: (message) => {
|
|
170
|
+
stale();
|
|
171
|
+
if (typeof message !== "string") {
|
|
172
|
+
throw new Error(`extension command "/${name}" say() needs a string`);
|
|
173
|
+
}
|
|
174
|
+
if (message.length === 0)
|
|
175
|
+
return;
|
|
176
|
+
staged.push(message);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
let returned;
|
|
180
|
+
try {
|
|
181
|
+
returned = await rec.handler(ctx);
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
return { ok: false, error: `Error: extension command "/${name}" failed: ${errorText(e)}` };
|
|
185
|
+
}
|
|
186
|
+
if (typeof returned === "string" && returned.length > 0)
|
|
187
|
+
staged.push(returned);
|
|
188
|
+
try {
|
|
189
|
+
for (const message of staged)
|
|
190
|
+
deps.say(message);
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
return { ok: false, error: `Error: extension command "/${name}" failed: ${errorText(e)}` };
|
|
194
|
+
}
|
|
195
|
+
return { ok: true, posted: staged.length };
|
|
196
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Extension UI surface (ticket 10): the dependency-free render model behind
|
|
2
|
+
// ExtensionAPI.setStatusSegment/setWidget/notify/promptUser. No imports, no
|
|
3
|
+
// React — like extension-commands.ts and tools/intercept.ts — so the host,
|
|
4
|
+
// the App, and pure unit tests share it with no cycle: the host validates
|
|
5
|
+
// and stages here, the App renders from here, nobody imports the other.
|
|
6
|
+
//
|
|
7
|
+
// Shapes:
|
|
8
|
+
// - Status: one text slot per extension (upsert by owner name). The bar
|
|
9
|
+
// budget lives with the render (status-bar.tsx states the fixed-width
|
|
10
|
+
// rule); this module only validates shape so a bad segment fails
|
|
11
|
+
// activation loudly instead of corrupting the bar.
|
|
12
|
+
// - Widget: titled text blocks keyed by owner + id (default "main"),
|
|
13
|
+
// rendered by the App in the configured placement. "panel" is the only
|
|
14
|
+
// placement in v1 (the bordered panel above the input zone, beside the
|
|
15
|
+
// todo panel) — unknown placements throw fail-closed so a typo surfaces
|
|
16
|
+
// at activation instead of rendering nowhere.
|
|
17
|
+
// - Notices: transient fire-and-forget strings the App drains into the
|
|
18
|
+
// transcript (one `(owner) message` info line each). Sync and bounded
|
|
19
|
+
// by flow — notify() never blocks, headless or not; the host caps the
|
|
20
|
+
// staged queue drop-oldest (EXT_NOTICE_CAP in extensions.ts).
|
|
21
|
+
// - Dialogs: validated { question, options, allowCustom } specs mirroring
|
|
22
|
+
// the QuestionBox the App fulfills them with (same option caps the modal
|
|
23
|
+
// already assumes). The pending-promise mechanics live in extensions.ts
|
|
24
|
+
// (runtime-local, generation-bound); this module only validates shape.
|
|
25
|
+
export const EXT_STATUS_SEGMENT_MAX = 24;
|
|
26
|
+
export const EXT_STATUS_TOTAL_MAX = 40;
|
|
27
|
+
export const EXT_WIDGET_TITLE_MAX = 48;
|
|
28
|
+
export const EXT_WIDGET_TEXT_MAX = 500;
|
|
29
|
+
export const EXT_NOTIFY_MAX = 200;
|
|
30
|
+
export const EXT_DIALOG_QUESTION_MAX = 200;
|
|
31
|
+
export const EXT_DIALOG_OPTIONS_MAX = 8;
|
|
32
|
+
export const EXT_DIALOG_OPTION_MAX = 80;
|
|
33
|
+
// v1 widget placements. The App renders "panel" above the input zone;
|
|
34
|
+
// anything else is a loud validation error (never a silent nowhere).
|
|
35
|
+
export const EXT_WIDGET_PLACEMENTS = ["panel"];
|
|
36
|
+
const WIDGET_ID_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
37
|
+
function isRecord(value) {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
function errorFor(owner, what) {
|
|
41
|
+
return `extension "${owner}" ${what}`;
|
|
42
|
+
}
|
|
43
|
+
/** Validate a status segment. Throws Error on any problem. Returns the trimmed text. */
|
|
44
|
+
export function validateStatusSegment(owner, text) {
|
|
45
|
+
if (typeof text !== "string" || text.trim().length === 0) {
|
|
46
|
+
throw new Error(errorFor(owner, "status segment needs a non-empty string"));
|
|
47
|
+
}
|
|
48
|
+
if (text.length > EXT_STATUS_SEGMENT_MAX * 4) {
|
|
49
|
+
throw new Error(errorFor(owner, `status segment is too long (${text.length} chars, max ${EXT_STATUS_SEGMENT_MAX * 4})`));
|
|
50
|
+
}
|
|
51
|
+
return text;
|
|
52
|
+
}
|
|
53
|
+
/** Validate a widget definition. Throws Error on any problem. */
|
|
54
|
+
export function validateWidgetDef(owner, def) {
|
|
55
|
+
if (!isRecord(def))
|
|
56
|
+
throw new Error(errorFor(owner, "widget definition must be an object"));
|
|
57
|
+
const id = def.id === undefined ? "main" : def.id;
|
|
58
|
+
if (typeof id !== "string" || !WIDGET_ID_RE.test(id)) {
|
|
59
|
+
throw new Error(errorFor(owner, `widget has an invalid id ${JSON.stringify(def.id)} (want 1-32 char a-z0-9_-, default "main")`));
|
|
60
|
+
}
|
|
61
|
+
if (!EXT_WIDGET_PLACEMENTS.includes(def.placement)) {
|
|
62
|
+
throw new Error(errorFor(owner, `widget "${id}" has an unknown placement ${JSON.stringify(def.placement)} (want one of: ${EXT_WIDGET_PLACEMENTS.join(", ")})`));
|
|
63
|
+
}
|
|
64
|
+
if (typeof def.title !== "string" || def.title.trim().length === 0) {
|
|
65
|
+
throw new Error(errorFor(owner, `widget "${id}" needs a non-empty title`));
|
|
66
|
+
}
|
|
67
|
+
if (def.title.length > EXT_WIDGET_TITLE_MAX) {
|
|
68
|
+
throw new Error(errorFor(owner, `widget "${id}" title is too long (${def.title.length} chars, max ${EXT_WIDGET_TITLE_MAX})`));
|
|
69
|
+
}
|
|
70
|
+
if (typeof def.text !== "string" || def.text.trim().length === 0) {
|
|
71
|
+
throw new Error(errorFor(owner, `widget "${id}" needs a non-empty text body`));
|
|
72
|
+
}
|
|
73
|
+
if (def.text.length > EXT_WIDGET_TEXT_MAX) {
|
|
74
|
+
throw new Error(errorFor(owner, `widget "${id}" text is too long (${def.text.length} chars, max ${EXT_WIDGET_TEXT_MAX})`));
|
|
75
|
+
}
|
|
76
|
+
return { id, placement: def.placement, title: def.title, text: def.text };
|
|
77
|
+
}
|
|
78
|
+
/** Validate a notification message. Throws Error on any problem. Returns the message. */
|
|
79
|
+
export function validateNotifyMessage(owner, message) {
|
|
80
|
+
if (typeof message !== "string" || message.trim().length === 0) {
|
|
81
|
+
throw new Error(errorFor(owner, "notification needs a non-empty string"));
|
|
82
|
+
}
|
|
83
|
+
if (message.length > EXT_NOTIFY_MAX * 4) {
|
|
84
|
+
throw new Error(errorFor(owner, `notification is too long (${message.length} chars, max ${EXT_NOTIFY_MAX * 4})`));
|
|
85
|
+
}
|
|
86
|
+
return message;
|
|
87
|
+
}
|
|
88
|
+
/** Validate a dialog spec. Throws Error on any problem. */
|
|
89
|
+
export function validateDialogDef(owner, def) {
|
|
90
|
+
if (!isRecord(def))
|
|
91
|
+
throw new Error(errorFor(owner, "dialog definition must be an object"));
|
|
92
|
+
if (typeof def.question !== "string" || def.question.trim().length === 0) {
|
|
93
|
+
throw new Error(errorFor(owner, "dialog needs a non-empty question"));
|
|
94
|
+
}
|
|
95
|
+
if (def.question.length > EXT_DIALOG_QUESTION_MAX) {
|
|
96
|
+
throw new Error(errorFor(owner, `dialog question is too long (${def.question.length} chars, max ${EXT_DIALOG_QUESTION_MAX})`));
|
|
97
|
+
}
|
|
98
|
+
const options = def.options === undefined ? [] : def.options;
|
|
99
|
+
if (!Array.isArray(options)) {
|
|
100
|
+
throw new Error(errorFor(owner, "dialog options must be an array of strings"));
|
|
101
|
+
}
|
|
102
|
+
if (options.length > EXT_DIALOG_OPTIONS_MAX) {
|
|
103
|
+
throw new Error(errorFor(owner, `dialog has too many options (${options.length}, max ${EXT_DIALOG_OPTIONS_MAX})`));
|
|
104
|
+
}
|
|
105
|
+
for (const o of options) {
|
|
106
|
+
if (typeof o !== "string" || o.trim().length === 0) {
|
|
107
|
+
throw new Error(errorFor(owner, "dialog options must be non-empty strings"));
|
|
108
|
+
}
|
|
109
|
+
if (o.length > EXT_DIALOG_OPTION_MAX) {
|
|
110
|
+
throw new Error(errorFor(owner, `dialog option is too long (${o.length} chars, max ${EXT_DIALOG_OPTION_MAX})`));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!options.length && def.allowCustom !== true) {
|
|
114
|
+
throw new Error(errorFor(owner, "dialog needs options or allowCustom: true (nothing to answer with)"));
|
|
115
|
+
}
|
|
116
|
+
return { question: def.question, options: [...options], allowCustom: def.allowCustom === true };
|
|
117
|
+
}
|
|
118
|
+
// Truncate text to n chars max for tight widths (`…/tail` keeps the
|
|
119
|
+
// meaningful end, the status-bar convention). n < 4 yields "" (the caller
|
|
120
|
+
// drops the segment instead of rendering a stub).
|
|
121
|
+
export function truncateSegment(s, n) {
|
|
122
|
+
if (s.length <= n)
|
|
123
|
+
return s;
|
|
124
|
+
if (n < 4)
|
|
125
|
+
return "";
|
|
126
|
+
return `…/${s.slice(-(n - 3))}`;
|
|
127
|
+
}
|
|
128
|
+
// Pure status-bar text for extension segments (unit-tested; the bar itself
|
|
129
|
+
// only decides fit-or-drop against the terminal width, never the content).
|
|
130
|
+
// Each segment truncates to EXT_STATUS_SEGMENT_MAX, joined with " · "; the
|
|
131
|
+
// total caps at EXT_STATUS_TOTAL_MAX with trailing segments dropped whole
|
|
132
|
+
// (never a mid-segment cut past the per-segment truncation). Null when
|
|
133
|
+
// nothing renderable remains.
|
|
134
|
+
export function formatExtensionStatusText(segments) {
|
|
135
|
+
const parts = [];
|
|
136
|
+
let len = 0;
|
|
137
|
+
for (const raw of segments) {
|
|
138
|
+
if (typeof raw !== "string")
|
|
139
|
+
continue;
|
|
140
|
+
const text = raw.trim();
|
|
141
|
+
if (!text)
|
|
142
|
+
continue;
|
|
143
|
+
const seg = truncateSegment(text, EXT_STATUS_SEGMENT_MAX);
|
|
144
|
+
if (!seg)
|
|
145
|
+
continue;
|
|
146
|
+
const add = (parts.length > 0 ? 3 : 0) + seg.length;
|
|
147
|
+
if (len + add > EXT_STATUS_TOTAL_MAX)
|
|
148
|
+
continue;
|
|
149
|
+
parts.push(seg);
|
|
150
|
+
len += add;
|
|
151
|
+
}
|
|
152
|
+
return parts.length > 0 ? parts.join(" · ") : null;
|
|
153
|
+
}
|