@xynogen/pix-pretty 1.8.0 → 1.10.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/README.md +4 -4
- package/package.json +3 -2
- package/src/icon-catalog.test.ts +26 -0
- package/src/icon-catalog.ts +14 -0
- package/src/progress.ts +1 -1
- package/src/utils.ts +4 -3
- package/src/widget-format.test.ts +114 -0
- package/src/widget-format.ts +168 -0
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ consume. It does not register user-facing tools itself — the tool renderers
|
|
|
11
11
|
initializes the syntax-highlight theme from Pi settings, clears the highlight
|
|
12
12
|
cache, seeds the icon mode from `pix.json`, and registers two FFF slash
|
|
13
13
|
commands (`/fff-health`, `/fff-rescan`) once `pix-grep` has brought the FFF
|
|
14
|
-
finder online. The `/pix` settings command lives in `pix-
|
|
14
|
+
finder online. The `/pix` settings command lives in `pix-runtime`.
|
|
15
15
|
(Activated by `pix-core`; not a standalone extension.)
|
|
16
16
|
|
|
17
17
|
### Rendering
|
|
@@ -36,7 +36,7 @@ problem on terminals without a Nerd Font, becomes a one-file edit here.
|
|
|
36
36
|
`getIconMode()`, `setIconMode()`, `ICON_KEYS`, `ICON_MODES`.
|
|
37
37
|
- **`./icon-persist`** — reads/writes the icon mode via `pix.json`
|
|
38
38
|
(`pretty.icons`); `initIconMode()` applies it on load.
|
|
39
|
-
- **`/pix`** (in `pix-
|
|
39
|
+
- **`/pix`** (in `pix-runtime`) — unified settings overlay that includes the icon
|
|
40
40
|
mode switch. One global knob governs every pix-* package (footer, paste
|
|
41
41
|
chips, model picker, welcome banner, optimizer cell). Seeded from
|
|
42
42
|
`PRETTY_ICONS` (`none`/`off` → `ascii`) when no choice is saved.
|
|
@@ -62,9 +62,9 @@ pi install npm:@xynogen/pix-pretty
|
|
|
62
62
|
|
|
63
63
|
## Configuration
|
|
64
64
|
|
|
65
|
-
Configuration is read from **`~/.pi/agent/pix.json`** (the unified config file
|
|
65
|
+
Configuration is read from **`~/.pi/agent/pix.json`** (the unified config file owned by `@xynogen/pix-runtime/config`). The `pretty` section of that file sets the defaults for theme, icon mode, and preview lines. Environment variables still override `pix.json` values.
|
|
66
66
|
|
|
67
|
-
> **Note:**
|
|
67
|
+
> **Note:** config and collapse helpers previously shipped with `pix-pretty`; they now live in `pix-runtime` (`@xynogen/pix-runtime/config` and `@xynogen/pix-runtime/collapse`). Update any direct imports.
|
|
68
68
|
|
|
69
69
|
### `pix.json` — `pretty` section
|
|
70
70
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-pretty",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Enhanced tool output rendering with syntax highlighting, file icons, tree views, diff rendering, and FFF search",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"./fff": "./src/fff.ts",
|
|
22
22
|
"./types": "./src/types.ts",
|
|
23
23
|
"./utils": "./src/utils.ts",
|
|
24
|
+
"./widget-format": "./src/widget-format.ts",
|
|
24
25
|
"./resize": "./src/resize.ts",
|
|
25
26
|
"./context": "./src/tools/context.ts",
|
|
26
27
|
"./gate-overlay": "./src/gate-overlay.ts",
|
|
@@ -59,7 +60,7 @@
|
|
|
59
60
|
},
|
|
60
61
|
"dependencies": {
|
|
61
62
|
"@xynogen/pix-data": "^0.4.0",
|
|
62
|
-
"@xynogen/pix-runtime": "^0.
|
|
63
|
+
"@xynogen/pix-runtime": "^0.4.0",
|
|
63
64
|
"chalk": "^4.1.2",
|
|
64
65
|
"cli-highlight": "^2.1.11",
|
|
65
66
|
"@ff-labs/fff-node": "^0.5.2",
|
package/src/icon-catalog.test.ts
CHANGED
|
@@ -39,6 +39,32 @@ describe("icon-catalog", () => {
|
|
|
39
39
|
}
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
it("status family keeps historical nerd glyphs and gains ascii tokens", () => {
|
|
43
|
+
// nerd mode must equal the pre-catalog literals so mixed-glyph rows and
|
|
44
|
+
// existing snapshot assertions stay aligned.
|
|
45
|
+
expect(iconFor("status.ok", "nerd")).toBe("\u2713");
|
|
46
|
+
expect(iconFor("status.error", "nerd")).toBe("\u2717");
|
|
47
|
+
expect(iconFor("status.warn", "nerd")).toBe("\u26A0");
|
|
48
|
+
expect(iconFor("status.pending", "nerd")).toBe("\u25CB");
|
|
49
|
+
expect(iconFor("status.running", "nerd")).toBe("\u25D0");
|
|
50
|
+
expect(iconFor("status.active", "nerd")).toBe("\u25CF");
|
|
51
|
+
expect(iconFor("status.done", "nerd")).toBe("\u25CF");
|
|
52
|
+
expect(iconFor("status.blocked", "nerd")).toBe("\u2298");
|
|
53
|
+
// ascii mode must be tofu-free (letters/punctuation only).
|
|
54
|
+
for (const key of [
|
|
55
|
+
"status.ok",
|
|
56
|
+
"status.error",
|
|
57
|
+
"status.warn",
|
|
58
|
+
"status.pending",
|
|
59
|
+
"status.running",
|
|
60
|
+
"status.active",
|
|
61
|
+
"status.done",
|
|
62
|
+
"status.blocked",
|
|
63
|
+
] as const) {
|
|
64
|
+
expect(iconFor(key, "ascii")).toMatch(/^[\x20-\x7e]+$/);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
42
68
|
it("unknown key fails soft to empty string", () => {
|
|
43
69
|
// @ts-expect-error exercising the runtime guard
|
|
44
70
|
expect(icon("does.not.exist")).toBe("");
|
package/src/icon-catalog.ts
CHANGED
|
@@ -58,6 +58,20 @@ const CATALOG = {
|
|
|
58
58
|
warn: { nerd: "\u26A0", unicode: "\u26A0", ascii: "!" },
|
|
59
59
|
error: { nerd: "\u2717", unicode: "\u2717", ascii: "x" },
|
|
60
60
|
|
|
61
|
+
// ── shared status glyphs (checklists, panels, markers) ────────────────
|
|
62
|
+
// nerd/unicode keep the historical literal so mixed-glyph rows stay
|
|
63
|
+
// aligned; ascii mode swaps in tofu-free tokens. `⚡` (energetic
|
|
64
|
+
// warning/killed/denied) intentionally stays a local literal — it is not
|
|
65
|
+
// part of this set.
|
|
66
|
+
"status.ok": { nerd: "\u2713", unicode: `\u2713${VS}`, ascii: "ok" },
|
|
67
|
+
"status.error": { nerd: "\u2717", unicode: `\u2717${VS}`, ascii: "x" },
|
|
68
|
+
"status.warn": { nerd: "\u26A0", unicode: `\u26A0${VS}`, ascii: "!" },
|
|
69
|
+
"status.pending": { nerd: "\u25CB", unicode: `\u25CB${VS}`, ascii: "o" },
|
|
70
|
+
"status.running": { nerd: "\u25D0", unicode: `\u25D0${VS}`, ascii: "*" },
|
|
71
|
+
"status.active": { nerd: "\u25CF", unicode: `\u25CF${VS}`, ascii: "*" },
|
|
72
|
+
"status.done": { nerd: "\u25CF", unicode: `\u25CF${VS}`, ascii: "x" },
|
|
73
|
+
"status.blocked": { nerd: "\u2298", unicode: `\u2298${VS}`, ascii: "!" },
|
|
74
|
+
|
|
61
75
|
// ── welcome banner ────────────────────────────────────────────────────
|
|
62
76
|
ready: { nerd: "\u{F0633}", unicode: `\u2713${VS}`, ascii: "ok" },
|
|
63
77
|
|
package/src/progress.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { frameModal, MIN_MODAL_HEIGHT, modalWidth, terminalModalHeight } from "./modal-frame.js";
|
|
17
|
+
import { SPINNER } from "./widget-format.js";
|
|
17
18
|
|
|
18
19
|
interface ProgressTheme {
|
|
19
20
|
fg(color: string, text: string): string;
|
|
@@ -46,7 +47,6 @@ export interface ProgressHandle {
|
|
|
46
47
|
close(): void;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
50
50
|
// 120ms: smooth enough to read as motion, slow enough to barely touch the
|
|
51
51
|
// render queue. The overlay owns input so this isn't competing with echo.
|
|
52
52
|
const SPINNER_INTERVAL_MS = 120;
|
package/src/utils.ts
CHANGED
|
@@ -230,9 +230,10 @@ export function lnum(n: number, w: number): string {
|
|
|
230
230
|
// ---------------------------------------------------------------------------
|
|
231
231
|
|
|
232
232
|
export function humanSize(bytes: number): string {
|
|
233
|
-
if (bytes < 1024) return `${bytes}B`;
|
|
234
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}
|
|
235
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)}
|
|
233
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
234
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
|
235
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
|
236
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
|
|
236
237
|
}
|
|
237
238
|
|
|
238
239
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
describeActivity,
|
|
4
|
+
fmtTokenCount,
|
|
5
|
+
formatContext,
|
|
6
|
+
formatMs,
|
|
7
|
+
formatSpeed,
|
|
8
|
+
formatTokens,
|
|
9
|
+
formatToolUses,
|
|
10
|
+
formatTurns,
|
|
11
|
+
getSessionContextPercent,
|
|
12
|
+
getSessionContextUsage,
|
|
13
|
+
SPINNER,
|
|
14
|
+
truncateLine,
|
|
15
|
+
} from "./widget-format.ts";
|
|
16
|
+
|
|
17
|
+
const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
18
|
+
|
|
19
|
+
describe("widget formatters", () => {
|
|
20
|
+
test("SPINNER has frames to cycle", () => {
|
|
21
|
+
expect(SPINNER.length).toBeGreaterThan(1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("fmtTokenCount scales with magnitude", () => {
|
|
25
|
+
expect(fmtTokenCount(500)).toBe("500");
|
|
26
|
+
expect(fmtTokenCount(30_100)).toBe("30.1K");
|
|
27
|
+
expect(fmtTokenCount(1_000_000)).toBe("1.00M");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("formatTokens uses ' token' / 'k token' / 'M token' variants", () => {
|
|
31
|
+
expect(stripAnsi(formatTokens(500))).toContain("500 token");
|
|
32
|
+
expect(stripAnsi(formatTokens(12_400))).toContain("12.4k token");
|
|
33
|
+
expect(stripAnsi(formatTokens(2_500_000))).toContain("2.5M token");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("formatMs renders seconds to one decimal", () => {
|
|
37
|
+
expect(formatMs(2_100)).toBe("2.1s");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("formatSpeed returns empty when there is no work", () => {
|
|
41
|
+
expect(formatSpeed(0, 1_000)).toBe("");
|
|
42
|
+
expect(formatSpeed(100, 0)).toBe("");
|
|
43
|
+
expect(stripAnsi(formatSpeed(200, 2_000))).toBe("100 t/s");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("formatContext shows used/window/percent, or empty when unknown", () => {
|
|
47
|
+
expect(formatContext(null)).toBe("");
|
|
48
|
+
expect(formatContext({ tokens: null, contextWindow: null, percent: null })).toBe("");
|
|
49
|
+
expect(
|
|
50
|
+
stripAnsi(formatContext({ tokens: 30_100, contextWindow: 1_000_000, percent: 3 })),
|
|
51
|
+
).toContain("30.1K/1.00M (3%)");
|
|
52
|
+
expect(stripAnsi(formatContext({ tokens: null, contextWindow: null, percent: 42 }))).toContain(
|
|
53
|
+
"42% ctx",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("formatTurns and formatToolUses render counts", () => {
|
|
58
|
+
expect(stripAnsi(formatTurns(3))).toContain("3");
|
|
59
|
+
expect(stripAnsi(formatTurns(3, 10))).toContain("3\u226410");
|
|
60
|
+
expect(stripAnsi(formatToolUses(5))).toContain("5");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("truncateLine tail-anchors the latest non-empty line to len", () => {
|
|
64
|
+
expect(truncateLine("short", 32)).toBe("short");
|
|
65
|
+
expect(truncateLine("a\nb\nlatest", 32)).toBe("latest");
|
|
66
|
+
expect(truncateLine("0123456789", 4)).toBe("\u20266789");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("describeActivity groups active tools, tails text (default 32), else thinking", () => {
|
|
70
|
+
const two = new Map<string, string>([
|
|
71
|
+
["0", "read"],
|
|
72
|
+
["1", "read"],
|
|
73
|
+
]);
|
|
74
|
+
expect(describeActivity(two)).toBe("reading 2\u00d7\u2026");
|
|
75
|
+
expect(describeActivity(new Map(), "line one\nlatest line")).toBe("latest line");
|
|
76
|
+
expect(describeActivity(new Map())).toBe("thinking\u2026");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("describeActivity honors an explicit tailLen", () => {
|
|
80
|
+
expect(describeActivity(new Map(), "0123456789", 4)).toBe("\u20266789");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("getSessionContextUsage reads stats and tolerates throwing sessions", () => {
|
|
84
|
+
const session = {
|
|
85
|
+
getSessionStats: () => ({
|
|
86
|
+
tokens: { input: 0, output: 0, cacheWrite: 0 },
|
|
87
|
+
contextUsage: { tokens: 10, contextWindow: 100, percent: 10 },
|
|
88
|
+
}),
|
|
89
|
+
};
|
|
90
|
+
expect(getSessionContextUsage(session)).toEqual({
|
|
91
|
+
tokens: 10,
|
|
92
|
+
contextWindow: 100,
|
|
93
|
+
percent: 10,
|
|
94
|
+
});
|
|
95
|
+
expect(getSessionContextUsage(undefined)).toBeNull();
|
|
96
|
+
const throwing = {
|
|
97
|
+
getSessionStats: () => {
|
|
98
|
+
throw new Error("no stats");
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
expect(getSessionContextUsage(throwing)).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("getSessionContextPercent returns just the percent, or null", () => {
|
|
105
|
+
const session = {
|
|
106
|
+
getSessionStats: () => ({
|
|
107
|
+
tokens: { input: 0, output: 0, cacheWrite: 0 },
|
|
108
|
+
contextUsage: { tokens: 10, contextWindow: 100, percent: 42 },
|
|
109
|
+
}),
|
|
110
|
+
};
|
|
111
|
+
expect(getSessionContextPercent(session)).toBe(42);
|
|
112
|
+
expect(getSessionContextPercent(undefined)).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* widget-format.ts — pure, shared live-widget formatting helpers.
|
|
3
|
+
*
|
|
4
|
+
* These are the token/context/turn/tool/speed formatters plus the session
|
|
5
|
+
* context-usage readers used by pix-subagent's agent widget and pix-commands'
|
|
6
|
+
* /btw widget. They are pure (no Theme, no Pi host) so both packages can import
|
|
7
|
+
* them from this sanctioned shared layer instead of duplicating the code.
|
|
8
|
+
*
|
|
9
|
+
* icon() is imported locally (this module lives inside pix-pretty).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { icon } from "./icon-catalog.ts";
|
|
13
|
+
|
|
14
|
+
// ── Braille spinner ──────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export const SPINNER = [
|
|
17
|
+
"\u280b",
|
|
18
|
+
"\u2819",
|
|
19
|
+
"\u2839",
|
|
20
|
+
"\u2838",
|
|
21
|
+
"\u283c",
|
|
22
|
+
"\u2834",
|
|
23
|
+
"\u2826",
|
|
24
|
+
"\u2827",
|
|
25
|
+
"\u2807",
|
|
26
|
+
"\u280f",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// ── Session-stats shapes + readers ─────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/** Minimal shape we read from upstream `getSessionStats()`. */
|
|
32
|
+
export type SessionStatsLike = {
|
|
33
|
+
tokens: { input: number; output: number; cacheWrite: number };
|
|
34
|
+
contextUsage?: { tokens?: number | null; contextWindow?: number; percent: number | null };
|
|
35
|
+
};
|
|
36
|
+
export type SessionLike = { getSessionStats(): SessionStatsLike };
|
|
37
|
+
|
|
38
|
+
/** Context usage snapshot: estimated used tokens, window size, percent. */
|
|
39
|
+
export type ContextUsageLike = {
|
|
40
|
+
tokens: number | null;
|
|
41
|
+
contextWindow: number | null;
|
|
42
|
+
percent: number | null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Full context usage, or null when unavailable. */
|
|
46
|
+
export function getSessionContextUsage(session: SessionLike | undefined): ContextUsageLike | null {
|
|
47
|
+
if (!session) return null;
|
|
48
|
+
try {
|
|
49
|
+
const cu = session.getSessionStats().contextUsage;
|
|
50
|
+
if (!cu) return null;
|
|
51
|
+
return {
|
|
52
|
+
tokens: cu.tokens ?? null,
|
|
53
|
+
contextWindow: cu.contextWindow ?? null,
|
|
54
|
+
percent: cu.percent ?? null,
|
|
55
|
+
};
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Context-window utilization (0–100), or null when unavailable
|
|
63
|
+
* (no model contextWindow, or post-compaction before the next response).
|
|
64
|
+
*/
|
|
65
|
+
export function getSessionContextPercent(session: SessionLike | undefined): number | null {
|
|
66
|
+
return getSessionContextUsage(session)?.percent ?? null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Formatters ─────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export function formatTokens(count: number): string {
|
|
72
|
+
const t = icon("tokens");
|
|
73
|
+
if (count >= 1_000_000) return `${t} ${(count / 1_000_000).toFixed(1)}M token`;
|
|
74
|
+
if (count >= 1_000) return `${t} ${(count / 1_000).toFixed(1)}k token`;
|
|
75
|
+
return `${t} ${count} token`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Compact token count: 500 → "500", 30_100 → "30.1K", 1_000_000 → "1.00M". */
|
|
79
|
+
export function fmtTokenCount(n: number): string {
|
|
80
|
+
if (n < 1_000) return `${n}`;
|
|
81
|
+
if (n < 1_000_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
82
|
+
return `${(n / 1_000_000).toFixed(2)}M`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Format context-window utilization: " 30.1K/1.00M (3%)".
|
|
87
|
+
* Falls back to " 3% ctx" when the window size is unknown.
|
|
88
|
+
* Returns "" when percent is null/unavailable (caller should skip the segment).
|
|
89
|
+
*/
|
|
90
|
+
export function formatContext(usage: ContextUsageLike | null | undefined): string {
|
|
91
|
+
if (usage?.percent == null) return "";
|
|
92
|
+
const t = icon("tokens");
|
|
93
|
+
const pct = Math.round(usage.percent);
|
|
94
|
+
if (!usage.contextWindow) return `${t} ${pct}% ctx`;
|
|
95
|
+
const used = usage.tokens ?? Math.round((usage.percent / 100) * usage.contextWindow);
|
|
96
|
+
return `${t} ${fmtTokenCount(used)}/${fmtTokenCount(usage.contextWindow)} (${pct}%)`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function formatTurns(turnCount: number, maxTurns?: number | null): string {
|
|
100
|
+
const t = icon("turns");
|
|
101
|
+
return maxTurns != null ? `${t} ${turnCount}≤${maxTurns}` : `${t} ${turnCount}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function formatToolUses(count: number): string {
|
|
105
|
+
return `${icon("tools")} ${count}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function formatMs(ms: number): string {
|
|
109
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Output tokens per second over a duration. "" when either input is
|
|
114
|
+
* non-positive (no work / zero elapsed) so callers can skip the segment.
|
|
115
|
+
*/
|
|
116
|
+
export function formatSpeed(outputTokens: number, durationMs: number): string {
|
|
117
|
+
if (outputTokens <= 0 || durationMs <= 0) return "";
|
|
118
|
+
return `${Math.round(outputTokens / (durationMs / 1000))} t/s`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Activity description ─────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
export const TOOL_DISPLAY: Record<string, string> = {
|
|
124
|
+
read: "reading",
|
|
125
|
+
bash: "running command",
|
|
126
|
+
edit: "editing",
|
|
127
|
+
write: "writing",
|
|
128
|
+
grep: "searching",
|
|
129
|
+
find: "finding files",
|
|
130
|
+
ls: "listing",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Live tail of agent output: latest non-empty line, tail-anchored to `len`
|
|
135
|
+
* chars (keeps the moving edge, not the stale first line).
|
|
136
|
+
*/
|
|
137
|
+
export function truncateLine(text: string, len = 32): string {
|
|
138
|
+
const lines = text.split("\n").filter((l) => l.trim());
|
|
139
|
+
const line = (lines.at(-1) ?? "").trim();
|
|
140
|
+
if (line.length <= len) return line;
|
|
141
|
+
return `\u2026${line.slice(-len)}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* One-line description of what an agent/job is doing: grouped active tools,
|
|
146
|
+
* else a tail (`tailLen` chars, default 32) of the streaming answer text, else
|
|
147
|
+
* "thinking…".
|
|
148
|
+
*/
|
|
149
|
+
export function describeActivity(
|
|
150
|
+
activeTools: Map<string, string>,
|
|
151
|
+
responseText?: string,
|
|
152
|
+
tailLen = 32,
|
|
153
|
+
): string {
|
|
154
|
+
if (activeTools.size > 0) {
|
|
155
|
+
const groups = new Map<string, number>();
|
|
156
|
+
for (const toolName of activeTools.values()) {
|
|
157
|
+
const action = TOOL_DISPLAY[toolName] ?? toolName;
|
|
158
|
+
groups.set(action, (groups.get(action) ?? 0) + 1);
|
|
159
|
+
}
|
|
160
|
+
const parts: string[] = [];
|
|
161
|
+
for (const [action, count] of groups) {
|
|
162
|
+
parts.push(count > 1 ? `${action} ${count}\u00d7` : action);
|
|
163
|
+
}
|
|
164
|
+
return `${parts.join(", ")}\u2026`;
|
|
165
|
+
}
|
|
166
|
+
if (responseText?.trim()) return truncateLine(responseText, tailLen);
|
|
167
|
+
return "thinking\u2026";
|
|
168
|
+
}
|