agy-powerline 1.3.0 → 1.5.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.
@@ -24,7 +24,7 @@ const {
24
24
  } = require("../lib/shared");
25
25
  const { resolveInstallDir } = require("../lib/paths");
26
26
  const { WIDGET_TAGS } = require("../lib/widget-style");
27
- const { editFields: getEditFields } = require("../lib/widget-options");
27
+ const { editFields: getEditFields, stepNumberField } = require("../lib/widget-options");
28
28
  const { loadEditableConfig } = require("./config-guard");
29
29
  const {
30
30
  parseColor,
@@ -295,7 +295,15 @@ const TAB_OF_SCREEN = {
295
295
  glyphs: "powerline",
296
296
  };
297
297
 
298
- const PRESET_ICONS = { default: "🎯", compact: "⚡", workspace: "📂", limits: "🔋", full: "🚀" };
298
+ const PRESET_ICONS = {
299
+ default: "🎯",
300
+ compact: "⚡",
301
+ agent: "🧠",
302
+ git: "🔀",
303
+ tokens: "📊",
304
+ quotas: "🔋",
305
+ full: "🚀",
306
+ };
299
307
 
300
308
  // Set by renderFrame so tests can draw a screen at a fixed size.
301
309
  let columnsOverride = null;
@@ -409,6 +417,7 @@ function previewParts(width) {
409
417
  // The toggle keys work on the widget list, so only that screen names them.
410
418
  const key = (letter) => (screen === "main" ? letter : "");
411
419
  const chips = [
420
+ ui.chip("gaps", preview.rowGap, key("v")),
412
421
  ui.chip("emojis", preview.emojis, key("e")),
413
422
  ui.chip("minimal", preview.minimalist, key("m")),
414
423
  ui.chip("pace", preview.pacing, key("r")),
@@ -416,10 +425,7 @@ function previewParts(width) {
416
425
  ui.chip("ascii", preview.ascii, key("i")),
417
426
  ];
418
427
  if (preview.layout === "pills") {
419
- chips.unshift(
420
- ui.chip("joins", preview.powerline.enabled, key("j")),
421
- ui.chip("gaps", preview.rowGap, key("v"))
422
- );
428
+ chips.unshift(ui.chip("joins", preview.powerline.enabled, key("j")));
423
429
  }
424
430
 
425
431
  return { title, bar: String(rendered).split("\n"), chips: chips.join(" ") };
@@ -849,8 +855,7 @@ function setOption(key, value) {
849
855
  function stepOption(field, direction) {
850
856
  const current = editedWidget()[field.key];
851
857
  if (field.number !== undefined) {
852
- const from = Number.isFinite(current) ? current : field.number;
853
- setOption(field.key, Math.max(0, Math.min(100, from + direction * 5)));
858
+ setOption(field.key, stepNumberField(field, current, direction));
854
859
  return;
855
860
  }
856
861
  const index = field.values.findIndex((value) => value === current);
@@ -870,11 +875,12 @@ function onEdit(key) {
870
875
  setOption(field.key, text || undefined);
871
876
  textInput = null;
872
877
  } else if (key.name === "backspace" || key.name === "delete") {
873
- textInput = textInput.slice(0, -1);
874
- } else if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
875
- const char = key.sequence;
876
- if (char >= " " && char !== "\x7f" && textInput.length < TEXT_MAX) {
877
- textInput += char;
878
+ textInput = Array.from(textInput).slice(0, -1).join("");
879
+ } else if (key.sequence && !key.ctrl && !key.meta && !/[\x00-\x1f\x7f]/.test(key.sequence)) {
880
+ // An emoji arrives as one key with two UTF-16 units, so count code points.
881
+ const max = field?.maxLength ?? TEXT_MAX;
882
+ if (Array.from(textInput + key.sequence).length <= max) {
883
+ textInput += key.sequence;
878
884
  }
879
885
  }
880
886
  return;
@@ -1029,21 +1035,38 @@ const toHex = (rgb) => `#${rgb.map((part) => part.toString(16).padStart(2, "0"))
1029
1035
 
1030
1036
  // One color as a pill in that color with the theme's pill text, the way the
1031
1037
  // bar draws it, then its hex value.
1032
- function colorLine(label, rgb, ink) {
1033
- return { raw: `${ui.pill(` ${label.padEnd(9)}`, rgb, ink)} ${dim(toHex(rgb))}` };
1034
- }
1035
-
1036
- const PALETTE_SLOTS = [
1037
- ["model", "Model"],
1038
- ["context", "Context"],
1039
- ["git", "Git"],
1040
- ["weekly", "7d quota"],
1041
- ["meta", "Details"],
1042
- ["fiveHour", "OK"],
1043
- ["caution", "Caution"],
1044
- ["warn", "Danger"],
1038
+ function colorCell(label, rgb, ink) {
1039
+ return `${ui.pill(` ${label.padEnd(9)}`, rgb, ink)} ${dim(toHex(rgb))}`;
1040
+ }
1041
+
1042
+ // Widget colors on the left, states on the right; a blank line between rows
1043
+ // keeps the pills from touching.
1044
+ const PALETTE_ROWS = [
1045
+ [
1046
+ ["model", "Model"],
1047
+ ["fiveHour", "OK"],
1048
+ ],
1049
+ [
1050
+ ["context", "Context"],
1051
+ ["caution", "Caution"],
1052
+ ],
1053
+ [
1054
+ ["git", "Git"],
1055
+ ["warn", "Danger"],
1056
+ ],
1057
+ [
1058
+ ["weekly", "7d quota"],
1059
+ ["meta", "Details"],
1060
+ ],
1045
1061
  ];
1046
1062
 
1063
+ function paletteLines(colors) {
1064
+ return PALETTE_ROWS.flatMap((row, index) => [
1065
+ ...(index ? [""] : []),
1066
+ { raw: row.map(([slot, label]) => colorCell(label, colors[slot], colors.text)).join(" ") },
1067
+ ]);
1068
+ }
1069
+
1047
1070
  function themeDetails(id) {
1048
1071
  if (id === "custom") {
1049
1072
  return {
@@ -1067,9 +1090,7 @@ function themeDetails(id) {
1067
1090
  ...(background ? [{ raw: `${dim("For")} ${ui.tag(background)}` }] : []),
1068
1091
  "",
1069
1092
  heading("Palette"),
1070
- ...PALETTE_SLOTS.map(([slot, label]) =>
1071
- colorLine(label, theme.colors[slot], theme.colors.text)
1072
- ),
1093
+ ...paletteLines(theme.colors),
1073
1094
  "",
1074
1095
  heading("Sample"),
1075
1096
  ...themeSample(theme.colors),
@@ -1204,7 +1225,7 @@ const POWERLINE_HELP = {
1204
1225
  enabled:
1205
1226
  "On: adjacent widgets share one pill, with a separator between them. Off: every widget gets its own pill.",
1206
1227
  rowGap:
1207
- "Puts a blank line between Pills rows, so stacked pills don't touch. The bar takes more lines (three rows become five).",
1228
+ "Puts a blank line between rows, in every layout, so stacked pills don't touch and text rows get more air. The bar takes more lines (three rows become five).",
1208
1229
  style: "Sets start caps, end caps and separators in one go. Enter cycles the styles.",
1209
1230
  separators: "Glyphs between widgets of a pill. With several, they cycle per join, left to right.",
1210
1231
  startCaps: "Glyphs that open each pill. With several, they cycle per pill, left to right.",
@@ -1233,7 +1254,7 @@ function powerlineDetails(row) {
1233
1254
  function drawPowerline() {
1234
1255
  const items = POWERLINE_ROWS.map((row) => ({ text: row.label, value: powerlineRowValue(row) }));
1235
1256
  const notes =
1236
- config.layout === "lines"
1257
+ config.layout !== "pills"
1237
1258
  ? ["Powerline shapes apply to Pills. Switch layout with l."]
1238
1259
  : [
1239
1260
  "A style sets caps and separators together; edit a list to fine-tune it.",
@@ -1318,10 +1339,24 @@ function drawGlyphs() {
1318
1339
  });
1319
1340
  }
1320
1341
 
1342
+ // Three short paragraphs per layout, each under 90 columns so a wide panel
1343
+ // shows them unwrapped.
1321
1344
  const LAYOUT_HELP = {
1322
- pills: "Widgets sit in colored pills. The Powerline tab sets their caps, separators and joins.",
1323
- lines:
1324
- "Colored text on the terminal background, with dividers and dot meters. Powerline shapes don't apply.",
1345
+ pills: [
1346
+ "Widgets sit in colored pills, joined by powerline caps and separators.",
1347
+ "Meters draw as small dots on the pill's color, with a larger dot as the pace marker.",
1348
+ "The Powerline tab sets the caps, separators, joins and the space between rows.",
1349
+ ],
1350
+ lines: [
1351
+ "Colored text on the terminal background, with thin │ dividers between widgets.",
1352
+ "Meters draw as dots in the ok, caution or danger color, over a dim track.",
1353
+ "Powerline shapes don't apply; the theme, widgets and space between rows do.",
1354
+ ],
1355
+ classic: [
1356
+ "Colored text on the terminal background, with | dividers between widgets.",
1357
+ "Meters draw as blocks in one color: █ filled, ░ empty and │ as the pace marker.",
1358
+ "Powerline shapes don't apply; the theme, widgets and space between rows do.",
1359
+ ],
1325
1360
  };
1326
1361
 
1327
1362
  function drawLayout() {
@@ -1335,10 +1370,9 @@ function drawLayout() {
1335
1370
  return screenFrame({
1336
1371
  title: "Layout",
1337
1372
  items,
1338
- notes: ["Both layouts use your widgets and theme; Powerline shapes apply to Pills."],
1339
1373
  details: hovered && {
1340
1374
  title: hovered.name,
1341
- lines: [`${hovered.description}.`, "", LAYOUT_HELP[hovered.id] ?? ""],
1375
+ lines: LAYOUT_HELP[hovered.id].flatMap((line, i) => (i ? ["", line] : [line])),
1342
1376
  },
1343
1377
  keys: [
1344
1378
  [ui.glyph.upDown, "preview"],
package/src/cli/doctor.js CHANGED
@@ -22,6 +22,7 @@ const {
22
22
  readConfigFile,
23
23
  setConfigDir,
24
24
  } = require("../lib/shared");
25
+ const { currentUserAndHost } = require("../lib/session");
25
26
  const { runStatuslineCommand } = require("../lib/run");
26
27
  const { detectColorDepth } = require("../lib/colors");
27
28
  const { readLog, getLogPath } = require("../lib/log");
@@ -55,6 +56,20 @@ function tildify(text) {
55
56
  return out;
56
57
  }
57
58
 
59
+ // The bar preview can hold the host widget's user@host, and doctor output
60
+ // gets pasted into issues: the names become user and host.
61
+ function maskHost(text, { username, hostname } = currentUserAndHost()) {
62
+ let out = String(text);
63
+ if (!hostname) {
64
+ return out;
65
+ }
66
+ if (username) {
67
+ out = out.split(`${username}@${hostname}`).join("user@host");
68
+ }
69
+ const escaped = hostname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
70
+ return out.replace(new RegExp(`(^|[^\\w.-])${escaped}(?![\\w.-])`, "g"), "$1host");
71
+ }
72
+
58
73
  function stripAnsi(text) {
59
74
  return String(text).replace(/\x1b\[[0-9;]*m/g, "");
60
75
  }
@@ -216,7 +231,7 @@ async function doctorCommand(options = {}) {
216
231
  line("spawn", `${GREEN}ok${RESET} (${ms}ms)`);
217
232
  // The flex separator pads to the terminal width; shrink those gaps so
218
233
  // the widgets fit in the preview on wide terminals.
219
- line("output", preview.replace(/ {3,}/g, " ").slice(0, 120));
234
+ line("output", maskHost(preview.replace(/ {3,}/g, " ")).slice(0, 120));
220
235
  } else {
221
236
  failed += 1;
222
237
  line("spawn", `${RED}failed${RESET} exit ${result.code} (${ms}ms)`);
@@ -244,4 +259,5 @@ async function doctorCommand(options = {}) {
244
259
 
245
260
  module.exports = {
246
261
  doctorCommand,
262
+ maskHost,
247
263
  };
package/src/index.js CHANGED
@@ -102,7 +102,8 @@ const COMMANDS = [
102
102
  {
103
103
  name: "preset",
104
104
  arg: "[name]",
105
- description: "View or apply a widget preset (default, compact, workspace, limits, full)",
105
+ description:
106
+ "View or apply a widget preset (default, compact, agent, git, tokens, quotas, full)",
106
107
  options: {
107
108
  list: { type: "boolean", short: "l", help: "list all available presets" },
108
109
  },
@@ -122,6 +122,20 @@ const WIDGET_CATALOG = [
122
122
  description: "name of the project folder",
123
123
  summary: "project folder name",
124
124
  },
125
+ {
126
+ id: "os",
127
+ label: "Operating System",
128
+ unique: true,
129
+ description: "system agy runs on, including WSL",
130
+ summary: "system running agy",
131
+ },
132
+ {
133
+ id: "host",
134
+ label: "Host",
135
+ unique: true,
136
+ description: "current user and short host name",
137
+ summary: "current user and host",
138
+ },
125
139
  {
126
140
  id: "git",
127
141
  label: "VCS Branch",
@@ -257,6 +271,13 @@ const WIDGET_CATALOG = [
257
271
  description: "session tokens, as a total or split into input and output",
258
272
  summary: "session tokens, total or split",
259
273
  },
274
+ {
275
+ id: "clock",
276
+ label: "Clock",
277
+ unique: true,
278
+ description: "current local time, in the format set under clock",
279
+ summary: "current local time",
280
+ },
260
281
  {
261
282
  id: "tokensToday",
262
283
  label: "Tokens Today",
package/src/lib/colors.js CHANGED
@@ -91,21 +91,24 @@ function rgb(r, g, b) {
91
91
  return [r, g, b];
92
92
  }
93
93
 
94
- function rgbToAnsi256([r, g, b]) {
95
- if (r === g && g === b) {
96
- if (r < 8) {
97
- return 16;
98
- }
99
-
100
- if (r > 248) {
101
- return 231;
102
- }
94
+ // The nearest xterm color: the 6x6x6 cube's levels (0, 95, 135, ... 255) are
95
+ // not evenly spaced, and a near-gray reads better from the gray ramp, so both
96
+ // candidates are measured and the closer one wins (as tmux does).
97
+ const CUBE_LEVELS = [0, 95, 135, 175, 215, 255];
103
98
 
104
- return Math.round(((r - 8) / 247) * 24) + 232;
105
- }
106
-
107
- const to6 = (v) => Math.round((v / 255) * 5);
108
- return 16 + 36 * to6(r) + 6 * to6(g) + to6(b);
99
+ function rgbToAnsi256([r, g, b]) {
100
+ const toCube = (v) => (v < 48 ? 0 : v < 115 ? 1 : Math.floor((v - 35) / 40));
101
+ const [cr, cg, cb] = [toCube(r), toCube(g), toCube(b)];
102
+ const cube = [CUBE_LEVELS[cr], CUBE_LEVELS[cg], CUBE_LEVELS[cb]];
103
+
104
+ const average = (r + g + b) / 3;
105
+ const grayIndex = average > 238 ? 23 : Math.max(0, Math.floor((average - 3) / 10));
106
+ const level = 8 + 10 * grayIndex;
107
+ const distance = ([x, y, z]) => (x - r) ** 2 + (y - g) ** 2 + (z - b) ** 2;
108
+
109
+ return distance([level, level, level]) < distance(cube)
110
+ ? 232 + grayIndex
111
+ : 16 + 36 * cr + 6 * cg + cb;
109
112
  }
110
113
 
111
114
  function colorSeq(kind, [r, g, b]) {
package/src/lib/config.js CHANGED
@@ -39,6 +39,8 @@ const VALID_WIDGET_IDS = new Set([
39
39
  "contextTokens",
40
40
  "cwd",
41
41
  "project",
42
+ "os",
43
+ "host",
42
44
  "git",
43
45
  "gitChanges",
44
46
  "gitFiles",
@@ -58,6 +60,7 @@ const VALID_WIDGET_IDS = new Set([
58
60
  "cacheCreate",
59
61
  "cacheHit",
60
62
  "totalTokens",
63
+ "clock",
61
64
  "tokensToday",
62
65
  "tokensWeek",
63
66
  "exec",
@@ -91,9 +94,14 @@ const LAYOUTS = {
91
94
  name: "Lines",
92
95
  description: "Plain colored text with dividers and dot meters",
93
96
  },
97
+ classic: {
98
+ id: "classic",
99
+ name: "Classic",
100
+ description: "Plain colored text with | dividers and █░│ block meters",
101
+ },
94
102
  };
95
103
 
96
- const LAYOUT_ORDER = ["pills", "lines"];
104
+ const LAYOUT_ORDER = ["pills", "lines", "classic"];
97
105
 
98
106
  function resolveLayoutId(layoutId) {
99
107
  return LAYOUTS[layoutId] ? layoutId : "pills";
@@ -255,11 +263,12 @@ function saveConfig(config) {
255
263
  }
256
264
 
257
265
  // Presets set the widget list only; the layout (Pills or Lines) stays the one
258
- // the user picked. Each keeps all its widgets at 80 columns (full at 100).
266
+ // the user picked. Each answers one question and keeps all its widgets at 80
267
+ // columns (full at 100); widgets with nothing to show hide on their own.
259
268
  const PRESETS = {
260
269
  default: {
261
270
  id: "default",
262
- name: "Everyday",
271
+ name: "Default",
263
272
  description: "Model, context and git; 5h and 7d quotas with resets on a second row",
264
273
  widgets: [
265
274
  { id: "model" },
@@ -277,41 +286,84 @@ const PRESETS = {
277
286
  },
278
287
  compact: {
279
288
  id: "compact",
280
- name: "One line",
281
- description: "Short model, tightest quota, context % and git on a single row",
289
+ name: "Compact",
290
+ description: "One row: short model, tightest quota, context % and git",
282
291
  widgets: [{ id: "model", short: true }, { id: "quota" }, { id: "contextPct" }, { id: "git" }],
283
292
  },
284
- workspace: {
285
- id: "workspace",
286
- name: "Several repos",
287
- description: "Project, git, agent state and model; context, tightest quota and mode below",
293
+ agent: {
294
+ id: "agent",
295
+ name: "Agent activity",
296
+ description: "Agent state, subagents, title and mode; context, artifacts, quota",
297
+ widgets: [
298
+ { id: "agentState" },
299
+ { id: "tasks" },
300
+ { id: "title" },
301
+ { id: "separator" },
302
+ { id: "mode" },
303
+ { id: "sandbox" },
304
+ { id: "lineBreak" },
305
+ { id: "model", short: true },
306
+ { id: "contextPct" },
307
+ { id: "artifacts" },
308
+ { id: "separator" },
309
+ { id: "quota" },
310
+ ],
311
+ },
312
+ git: {
313
+ id: "git",
314
+ name: "Git focus",
315
+ description: "Project, branch, worktree, stash; changed files and lines, commit",
288
316
  widgets: [
289
317
  { id: "project" },
290
318
  { id: "git" },
291
- { id: "agentState" },
319
+ { id: "gitWorktree" },
320
+ { id: "gitStash" },
292
321
  { id: "separator" },
293
322
  { id: "model", short: true },
294
323
  { id: "lineBreak" },
295
- { id: "context" },
324
+ { id: "gitFiles" },
325
+ { id: "gitChanges" },
326
+ { id: "gitSha" },
327
+ { id: "separator" },
328
+ { id: "contextPct" },
329
+ { id: "quota" },
330
+ ],
331
+ },
332
+ tokens: {
333
+ id: "tokens",
334
+ name: "Token usage",
335
+ description: "Session tokens in and out, cache hit; tokens today and over 7 days",
336
+ widgets: [
337
+ { id: "model", short: true },
338
+ { id: "contextTokens" },
339
+ { id: "separator" },
340
+ { id: "totalTokens", split: true },
341
+ { id: "cacheHit" },
342
+ { id: "lineBreak" },
343
+ { id: "tokensToday" },
344
+ { id: "tokensWeek" },
296
345
  { id: "separator" },
297
346
  { id: "quota" },
298
- { id: "mode" },
299
347
  ],
300
348
  },
301
- limits: {
302
- id: "limits",
303
- name: "Heavy usage",
304
- description: "5h, 7d, third-party and Argon quotas with resets, one quota per row",
349
+ quotas: {
350
+ id: "quotas",
351
+ name: "Quota watch",
352
+ description: "Plan and model; 5h and 7d quotas side by side; third-party and Argon",
305
353
  widgets: [
354
+ { id: "plan" },
355
+ { id: "separator" },
356
+ { id: "model", short: true },
357
+ { id: "lineBreak" },
306
358
  { id: "fiveHourBar" },
307
359
  { id: "fiveHourReset" },
308
- { id: "lineBreak" },
360
+ { id: "separator" },
309
361
  { id: "weeklyBar" },
310
362
  { id: "weeklyReset" },
311
363
  { id: "lineBreak" },
312
364
  { id: "thirdPartyFiveHour" },
313
365
  { id: "thirdPartyWeekly" },
314
- { id: "lineBreak" },
366
+ { id: "separator" },
315
367
  { id: "argonWeekly" },
316
368
  { id: "argonReset" },
317
369
  ],
@@ -319,7 +371,7 @@ const PRESETS = {
319
371
  full: {
320
372
  id: "full",
321
373
  name: "Everything",
322
- description: "Session, project and git; context, tokens and cache; both quotas, on three rows",
374
+ description: "Session, project and git; context, tokens and cache; both quotas",
323
375
  widgets: [
324
376
  { id: "agentState" },
325
377
  { id: "tasks" },
@@ -344,7 +396,7 @@ const PRESETS = {
344
396
  },
345
397
  };
346
398
 
347
- const PRESET_ORDER = ["default", "compact", "workspace", "limits", "full"];
399
+ const PRESET_ORDER = ["default", "compact", "agent", "git", "tokens", "quotas", "full"];
348
400
 
349
401
  function applyPreset(baseConfig, presetName) {
350
402
  const preset = PRESETS[presetName] || PRESETS.default;
package/src/lib/fit.js CHANGED
@@ -6,9 +6,12 @@ const { charWidth, visibleWidth } = require("./formatters");
6
6
  // first entry first. Ids not listed (custom ones) go before all of them.
7
7
  const DROP_ORDER = [
8
8
  "customText",
9
+ "os",
10
+ "host",
9
11
  "gitStash",
10
12
  "gitWorktree",
11
13
  "title",
14
+ "clock",
12
15
  "version",
13
16
  "cacheCreate",
14
17
  "cacheRead",
@@ -374,7 +374,23 @@ function homePath(value) {
374
374
  return "";
375
375
  }
376
376
 
377
- const resolved = path.resolve(String(value));
377
+ const input = String(value);
378
+ if (input === "~" || /^~[\\/]/.test(input)) {
379
+ return input;
380
+ }
381
+ const windowsAbsolute = /^[a-z]:[\\/]|^\\\\/i.test(input);
382
+ const posixAbsolute = input.startsWith("/");
383
+
384
+ // Payload paths can come from another OS. Preserve absolute paths in that
385
+ // format instead of resolving them relative to this machine's cwd.
386
+ if (
387
+ (windowsAbsolute && process.platform !== "win32") ||
388
+ (posixAbsolute && process.platform === "win32")
389
+ ) {
390
+ return input;
391
+ }
392
+
393
+ const resolved = path.resolve(input);
378
394
  const home = path.resolve(os.homedir());
379
395
  const prefix =
380
396
  process.platform === "win32"
@@ -388,6 +404,60 @@ function homePath(value) {
388
404
  return resolved;
389
405
  }
390
406
 
407
+ function isValidDepth(value) {
408
+ return Number.isInteger(value) && value >= 1 && value <= 10;
409
+ }
410
+
411
+ // Keep the path's root and final `depth` folders, replacing any middle
412
+ // folders with one ellipsis. Accept either OS's path separators on every OS.
413
+ function shortenPath(value, depth, ascii = false) {
414
+ const text = String(value ?? "");
415
+ if (!text || !isValidDepth(depth)) {
416
+ return text;
417
+ }
418
+
419
+ let root = "";
420
+ let rest = text;
421
+ let separator = text.match(/[\\/]/)?.[0] || "/";
422
+ // A network share keeps \\server\share (or //server/share) as its root.
423
+ const unc = text.match(/^([\\/]{2}[^\\/]+[\\/][^\\/]+)([\\/])?(.*)$/);
424
+ const drive = text.match(/^([a-z]:)([\\/])(.*)$/i);
425
+
426
+ if (unc) {
427
+ root = unc[1];
428
+ separator = unc[2] || separator;
429
+ rest = unc[3];
430
+ } else if (drive) {
431
+ root = drive[1];
432
+ separator = drive[2];
433
+ rest = drive[3];
434
+ } else if (text === "~" || /^~[\\/]/.test(text)) {
435
+ root = "~";
436
+ if (text.length > 1) {
437
+ separator = text[1];
438
+ rest = text.slice(2);
439
+ } else {
440
+ rest = "";
441
+ }
442
+ } else if (text[0] === "/" || text[0] === "\\") {
443
+ root = text[0];
444
+ separator = text[0];
445
+ rest = text.slice(1);
446
+ }
447
+
448
+ const folders = rest.split(/[\\/]+/).filter(Boolean);
449
+ if (folders.length <= depth) {
450
+ return text;
451
+ }
452
+
453
+ const suffix = folders.slice(-depth).join(separator);
454
+ const ellipsis = ascii ? "..." : "…";
455
+ if (root === "/" || root === "\\") {
456
+ return `${root}${ellipsis}${separator}${suffix}`;
457
+ }
458
+ return `${root ? `${root}${separator}` : ""}${ellipsis}${separator}${suffix}`;
459
+ }
460
+
391
461
  function label(emojis, emoji, named, value) {
392
462
  if (emojis) {
393
463
  return named ? ` ${emoji} ${named}: ${value} ` : ` ${emoji} ${value} `;
@@ -560,15 +630,44 @@ function clipText(text, max, ascii = false) {
560
630
  const ellipsis = ascii ? "..." : "…";
561
631
  let out = "";
562
632
  let width = 0;
563
- for (const char of value) {
564
- const w = charWidth(char.codePointAt(0));
633
+ let index = 0;
634
+ const escape = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\]8;;[^\x1b\x07]*(?:\x07|\x1b\\))/y;
635
+ while (index < value.length) {
636
+ if (value[index] === "\x1b") {
637
+ escape.lastIndex = index;
638
+ const match = escape.exec(value);
639
+ if (match) {
640
+ out += match[0];
641
+ index += match[0].length;
642
+ continue;
643
+ }
644
+ }
645
+
646
+ const code = value.codePointAt(index);
647
+ const char = String.fromCodePoint(code);
648
+ const w = charWidth(code);
565
649
  if (width + w > max - ellipsis.length) {
566
650
  break;
567
651
  }
568
652
  out += char;
569
653
  width += w;
654
+ index += char.length;
570
655
  }
571
- return out.trimEnd() + ellipsis;
656
+ const clipped = out.trimEnd() + ellipsis;
657
+ return /\x1b\[[0-?]*[ -/]*m/.test(clipped) ? `${clipped}\x1b[0m` : clipped;
658
+ }
659
+
660
+ function isValidMaxWidth(value) {
661
+ return Number.isInteger(value) && value >= 4 && value <= 200;
662
+ }
663
+
664
+ function limitWidth(value, widget, ascii = false, fallback) {
665
+ const width = isValidMaxWidth(widget?.maxWidth)
666
+ ? widget.maxWidth
667
+ : isValidMaxWidth(fallback)
668
+ ? fallback
669
+ : null;
670
+ return width === null ? value : clipText(value, width, ascii);
572
671
  }
573
672
 
574
673
  // Removes escape sequences and control characters from text that did not
@@ -656,6 +755,10 @@ module.exports = {
656
755
  formatTasks,
657
756
  formatMode,
658
757
  clipText,
758
+ limitWidth,
759
+ isValidMaxWidth,
760
+ shortenPath,
761
+ isValidDepth,
659
762
  quotaColor,
660
763
  contextColor,
661
764
  resolveQuotaThresholds,