@towles/tool 0.0.123 → 0.0.124

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@towles/tool",
3
- "version": "0.0.123",
3
+ "version": "0.0.124",
4
4
  "description": "One off quality of life scripts that I use on a daily basis.",
5
5
  "homepage": "https://github.com/ChrisTowles/towles-tool#readme",
6
6
  "bugs": {
@@ -1,9 +1,11 @@
1
- import { createSignal, createEffect, createMemo, For, Show, onCleanup } from "solid-js";
1
+ import { createSignal, For, Show, onCleanup } from "solid-js";
2
2
  import type { Accessor } from "solid-js";
3
3
  import type { AgentStatus, SessionData, Theme } from "@tt-agentboard/runtime";
4
- import { SPINNERS, UNSEEN_ICON, BOLD, DIM, toneColor } from "../constants";
4
+ import { truncate } from "@tt-agentboard/runtime";
5
+ import { UNSEEN_ICON, BOLD, DIM, toneColor } from "../constants";
5
6
  import { DiffStats } from "./DiffStats";
6
- import { cacheBar, cacheBarColor, shortModel } from "./cache-bar";
7
+ import { cacheBarVisual, shortModel } from "./cache-bar";
8
+ import { liveStatusIcon, unseenTerminalColor } from "./status-visuals";
7
9
 
8
10
  const STATUS_TEXT: Record<AgentStatus, string> = {
9
11
  idle: "",
@@ -21,6 +23,7 @@ export interface SessionCardProps {
21
23
  isFocused: boolean;
22
24
  isCurrent: boolean;
23
25
  spinIdx: Accessor<number>;
26
+ now: Accessor<number>;
24
27
  theme: Accessor<Theme>;
25
28
  statusColors: Accessor<Theme["status"]>;
26
29
  focusedAgentIdx: number;
@@ -42,7 +45,7 @@ export function SessionCard(props: SessionCardProps) {
42
45
 
43
46
  const accentColor = () => {
44
47
  if (props.isCurrent) return P().green;
45
- if (isUnseenTerminal()) return unseenAccentColor();
48
+ if (isUnseenTerminal()) return unseenTerminalColor(status(), P());
46
49
  const s = status();
47
50
  if (s === "error") return P().red;
48
51
  if (s === "interrupted") return P().peach;
@@ -53,24 +56,14 @@ export function SessionCard(props: SessionCardProps) {
53
56
  return "transparent";
54
57
  };
55
58
 
56
- const unseenAccentColor = () => {
57
- const s = status();
58
- if (s === "error") return P().red;
59
- if (s === "interrupted") return P().peach;
60
- return P().teal;
61
- };
62
-
63
59
  const statusIcon = () => {
64
- const s = status();
65
- if (s === "running") return SPINNERS[props.spinIdx() % SPINNERS.length]!;
66
- if (s === "waiting") return "";
67
- if (s === "question") return "?";
68
- if (isUnseenTerminal()) return UNSEEN_ICON;
69
- return "";
60
+ const live = liveStatusIcon(status(), props.spinIdx());
61
+ if (live) return live;
62
+ return isUnseenTerminal() ? UNSEEN_ICON : "";
70
63
  };
71
64
 
72
65
  const statusColor = () => {
73
- if (isUnseenTerminal()) return unseenAccentColor();
66
+ if (isUnseenTerminal()) return unseenTerminalColor(status(), P());
74
67
  return SC()[status()];
75
68
  };
76
69
 
@@ -85,16 +78,8 @@ export function SessionCard(props: SessionCardProps) {
85
78
  return P().surface2;
86
79
  };
87
80
 
88
- const truncName = () => {
89
- const n = props.session.name;
90
- return n.length > 18 ? n.slice(0, 17) + "…" : n;
91
- };
92
-
93
- const truncBranch = () => {
94
- const b = props.session.branch;
95
- if (!b) return "";
96
- return b.length > 30 ? b.slice(0, 29) + "…" : b;
97
- };
81
+ const truncName = () => truncate(props.session.name, 18);
82
+ const truncBranch = () => (props.session.branch ? truncate(props.session.branch, 30) : "");
98
83
 
99
84
  const hasDiff = () => {
100
85
  const { linesAdded, linesRemoved, commitsDelta, filesChanged } = props.session;
@@ -126,14 +111,6 @@ export function SessionCard(props: SessionCardProps) {
126
111
 
127
112
  const agents = () => props.session.agents ?? [];
128
113
 
129
- const [now, setNow] = createSignal(Date.now());
130
- const needsTicker = createMemo(() => agents().some((a) => a.details?.cacheExpiresAt != null));
131
- createEffect(() => {
132
- if (!needsTicker()) return;
133
- const id = setInterval(() => setNow(Date.now()), 1000);
134
- onCleanup(() => clearInterval(id));
135
- });
136
-
137
114
  return (
138
115
  <box flexDirection="column" flexShrink={0}>
139
116
  <box
@@ -196,7 +173,7 @@ export function SessionCard(props: SessionCardProps) {
196
173
  palette={P}
197
174
  statusColors={props.statusColors}
198
175
  spinIdx={props.spinIdx}
199
- now={now}
176
+ now={props.now}
200
177
  isKeyboardFocused={i() === props.focusedAgentIdx}
201
178
  onDismiss={() => props.onDismissAgent(agent)}
202
179
  onFocusPane={() => props.onFocusAgentPane(agent)}
@@ -233,19 +210,20 @@ function AgentRow(props: AgentRowProps) {
233
210
 
234
211
  const icon = () => {
235
212
  if (isUnseen()) return UNSEEN_ICON;
236
- if (isTerminal())
237
- return props.agent.status === "done" ? "✓" : props.agent.status === "error" ? "✗" : "⚠";
238
- if (props.agent.status === "running") return SPINNERS[props.spinIdx() % SPINNERS.length]!;
239
- if (props.agent.status === "waiting") return "";
240
- if (props.agent.status === "question") return "?";
241
- return "○";
213
+ if (isTerminal()) {
214
+ if (props.agent.status === "done") return "✓";
215
+ if (props.agent.status === "error") return "✗";
216
+ return "";
217
+ }
218
+ return liveStatusIcon(props.agent.status, props.spinIdx()) || "○";
242
219
  };
243
220
 
244
221
  const color = () => {
245
222
  if (isTerminal()) {
223
+ if (isUnseen()) return unseenTerminalColor(props.agent.status, P());
246
224
  if (props.agent.status === "error") return P().red;
247
225
  if (props.agent.status === "interrupted") return P().peach;
248
- return isUnseen() ? P().teal : P().green;
226
+ return P().green;
249
227
  }
250
228
  return SC()[props.agent.status];
251
229
  };
@@ -324,23 +302,25 @@ function AgentRow(props: AgentRowProps) {
324
302
  const details = d();
325
303
  const model = () => (details.model ? shortModel(details.model) : "");
326
304
  const hasCache = () => details.cacheExpiresAt != null && details.cacheTtlMs != null;
327
- const bar = () =>
328
- hasCache() ? cacheBar(details.cacheExpiresAt!, details.cacheTtlMs!, props.now()) : "";
329
- const barColor = () =>
305
+ const visual = () =>
330
306
  hasCache()
331
- ? cacheBarColor(details.cacheExpiresAt!, details.cacheTtlMs!, props.now(), P())
332
- : P().overlay0;
307
+ ? cacheBarVisual(details.cacheExpiresAt!, details.cacheTtlMs!, props.now(), P())
308
+ : null;
333
309
  return (
334
310
  <Show when={model() || hasCache()}>
335
311
  <text truncate>
336
312
  <Show when={model()}>
337
313
  <span style={{ fg: P().subtext0, attributes: DIM }}>{model()}</span>
338
314
  </Show>
339
- <Show when={hasCache()}>
340
- <span style={{ fg: P().overlay0, attributes: DIM }}>
341
- {model() ? " · cache " : "cache "}
342
- </span>
343
- <span style={{ fg: barColor() }}>{bar()}</span>
315
+ <Show when={visual()}>
316
+ {(v) => (
317
+ <>
318
+ <span style={{ fg: P().overlay0, attributes: DIM }}>
319
+ {model() ? " · cache " : "cache "}
320
+ </span>
321
+ <span style={{ fg: v().color }}>{v().bar}</span>
322
+ </>
323
+ )}
344
324
  </Show>
345
325
  </text>
346
326
  </Show>
@@ -4,30 +4,30 @@ export const CACHE_BAR_WIDTH = 10;
4
4
  export const CACHE_BAR_FILLED = "▰";
5
5
  export const CACHE_BAR_EMPTY = "▱";
6
6
 
7
- /** Render a drain-down bar: full = freshly cached, empty = expired. */
8
- export function cacheBar(expiresAt: number, ttlMs: number, now: number): string {
7
+ export interface CacheBarVisual {
8
+ bar: string;
9
+ color: string;
10
+ }
11
+
12
+ /** Drain-down bar + traffic-light color: full/green when fresh, empty/grey when expired. */
13
+ export function cacheBarVisual(
14
+ expiresAt: number,
15
+ ttlMs: number,
16
+ now: number,
17
+ palette: Theme["palette"],
18
+ ): CacheBarVisual {
9
19
  const remaining = expiresAt - now;
10
- if (remaining <= 0 || ttlMs <= 0) return CACHE_BAR_EMPTY.repeat(CACHE_BAR_WIDTH);
20
+ if (remaining <= 0 || ttlMs <= 0) {
21
+ return { bar: CACHE_BAR_EMPTY.repeat(CACHE_BAR_WIDTH), color: palette.overlay0 };
22
+ }
11
23
  const fraction = Math.max(0, Math.min(1, remaining / ttlMs));
12
24
  const filled = Math.round(fraction * CACHE_BAR_WIDTH);
13
- return CACHE_BAR_FILLED.repeat(filled) + CACHE_BAR_EMPTY.repeat(CACHE_BAR_WIDTH - filled);
25
+ const bar = CACHE_BAR_FILLED.repeat(filled) + CACHE_BAR_EMPTY.repeat(CACHE_BAR_WIDTH - filled);
26
+ const color = fraction > 0.5 ? palette.green : fraction > 0.2 ? palette.yellow : palette.peach;
27
+ return { bar, color };
14
28
  }
15
29
 
16
30
  export function shortModel(model: string): string {
17
31
  if (!model) return "";
18
32
  return model.replace(/^claude-/, "").replace(/\[1m\]$/i, "");
19
33
  }
20
-
21
- export function cacheBarColor(
22
- expiresAt: number,
23
- ttlMs: number,
24
- now: number,
25
- palette: Theme["palette"],
26
- ): string {
27
- const remaining = expiresAt - now;
28
- if (remaining <= 0 || ttlMs <= 0) return palette.overlay0;
29
- const fraction = remaining / ttlMs;
30
- if (fraction > 0.5) return palette.green;
31
- if (fraction > 0.2) return palette.yellow;
32
- return palette.peach;
33
- }
@@ -0,0 +1,17 @@
1
+ import type { AgentStatus, Theme } from "@tt-agentboard/runtime";
2
+ import { SPINNERS } from "../constants";
3
+
4
+ /** Icon for a live (non-terminal) status. Returns "" for statuses without a glyph. */
5
+ export function liveStatusIcon(status: AgentStatus, spinIdx: number): string {
6
+ if (status === "running") return SPINNERS[spinIdx % SPINNERS.length]!;
7
+ if (status === "waiting") return "◉";
8
+ if (status === "question") return "?";
9
+ return "";
10
+ }
11
+
12
+ /** Accent color for a terminal agent whose final status the user hasn't seen yet. */
13
+ export function unseenTerminalColor(status: AgentStatus, palette: Theme["palette"]): string {
14
+ if (status === "error") return palette.red;
15
+ if (status === "interrupted") return palette.peach;
16
+ return palette.teal;
17
+ }
@@ -15,7 +15,13 @@ import type { Accessor } from "solid-js";
15
15
  import { createStore, reconcile } from "solid-js/store";
16
16
 
17
17
  import { ensureServer, SERVER_PORT, SERVER_HOST, resolveTheme } from "@tt-agentboard/runtime";
18
- import type { ServerMessage, SessionData, ClientCommand, Theme } from "@tt-agentboard/runtime";
18
+ import type {
19
+ ServerMessage,
20
+ SessionData,
21
+ ClientCommand,
22
+ ReorderDelta,
23
+ Theme,
24
+ } from "@tt-agentboard/runtime";
19
25
  import { SessionCard } from "./components/SessionCard";
20
26
  import { StatusBar } from "./components/StatusBar";
21
27
  import { computeSessionStatusCounts } from "./session-status";
@@ -369,6 +375,18 @@ function App() {
369
375
  onCleanup(() => clearInterval(interval));
370
376
  });
371
377
 
378
+ // Shared 1s clock for cache-countdown bars. One ticker for all cards,
379
+ // gated on whether any agent across all sessions is actively cached.
380
+ const [now, setNow] = createSignal(Date.now());
381
+ const hasActiveCache = createMemo(() =>
382
+ sessions.some((s) => s.agents?.some((a) => a.details?.cacheExpiresAt != null)),
383
+ );
384
+ createEffect(() => {
385
+ if (!hasActiveCache()) return;
386
+ const id = setInterval(() => setNow(Date.now()), 1000);
387
+ onCleanup(() => clearInterval(id));
388
+ });
389
+
372
390
  useKeyboard((key) => {
373
391
  const currentModal = modal();
374
392
 
@@ -397,13 +415,8 @@ function App() {
397
415
  if ((key.meta || key.option) && (key.name === "up" || key.name === "down")) {
398
416
  const focused = focusedSession();
399
417
  if (focused) {
400
- const delta: -1 | 1 | "top" | "bottom" = key.shift
401
- ? key.name === "up"
402
- ? "top"
403
- : "bottom"
404
- : key.name === "up"
405
- ? -1
406
- : 1;
418
+ const up = key.name === "up";
419
+ const delta: ReorderDelta = key.shift ? (up ? "top" : "bottom") : up ? "up" : "down";
407
420
  send({ type: "reorder-session", name: focused, delta });
408
421
  }
409
422
  return;
@@ -541,6 +554,7 @@ function App() {
541
554
  isFocused={isFocused(session.name)}
542
555
  isCurrent={session.name === currentSession()}
543
556
  spinIdx={spinIdx}
557
+ now={now}
544
558
  theme={theme}
545
559
  statusColors={S}
546
560
  focusedAgentIdx={
@@ -61,9 +61,11 @@ export type {
61
61
  QuitNotify,
62
62
  ServerMessage,
63
63
  ClientCommand,
64
+ ReorderDelta,
64
65
  MetadataTone,
65
66
  MetadataStatus,
66
67
  MetadataProgress,
67
68
  MetadataLogEntry,
68
69
  SessionMetadata,
69
70
  } from "./shared";
71
+ export { truncate } from "./text-utils";
@@ -1,12 +1,9 @@
1
1
  import type { MetadataTone, SessionMetadata } from "../shared";
2
+ import { truncate } from "../text-utils";
2
3
 
3
4
  const MAX_LOGS = 50;
4
5
  const MAX_MESSAGE_LENGTH = 500;
5
6
 
6
- function truncate(s: string, max: number = MAX_MESSAGE_LENGTH): string {
7
- return s.length > max ? s.slice(0, max - 1) + "…" : s;
8
- }
9
-
10
7
  export class SessionMetadataStore {
11
8
  private store = new Map<string, SessionMetadata>();
12
9
 
@@ -62,7 +59,7 @@ export class SessionMetadataStore {
62
59
  ): void {
63
60
  const meta = this.getOrCreate(session);
64
61
  meta.logs.push({
65
- message: truncate(entry.message),
62
+ message: truncate(entry.message, MAX_MESSAGE_LENGTH),
66
63
  tone: entry.tone,
67
64
  source: entry.source ? truncate(entry.source, 50) : undefined,
68
65
  ts: Date.now(),
@@ -5,6 +5,8 @@ interface PersistedSessionOrder {
5
5
  order?: unknown;
6
6
  }
7
7
 
8
+ export type ReorderDelta = "up" | "down" | "top" | "bottom";
9
+
8
10
  /**
9
11
  * Maintains custom session ordering for reorder-session commands.
10
12
  * Stores an ordered list of session names. The `apply` method takes
@@ -59,8 +61,8 @@ export class SessionOrder {
59
61
  }
60
62
  }
61
63
 
62
- /** Move a session: delta -1 = up, 1 = down, "top" / "bottom" = jump to end. */
63
- reorder(name: string, delta: -1 | 1 | "top" | "bottom"): void {
64
+ /** Move a session up/down by one, or jump it to top/bottom of the order. */
65
+ reorder(name: string, delta: ReorderDelta): void {
64
66
  const idx = this.order.indexOf(name);
65
67
  if (idx === -1) return;
66
68
  if (delta === "top") {
@@ -68,7 +70,8 @@ export class SessionOrder {
68
70
  } else if (delta === "bottom") {
69
71
  this.order = [...this.order.filter((n) => n !== name), name];
70
72
  } else {
71
- const newIdx = idx + delta;
73
+ const step = delta === "up" ? -1 : 1;
74
+ const newIdx = idx + step;
72
75
  if (newIdx < 0 || newIdx >= this.order.length) return;
73
76
  [this.order[idx], this.order[newIdx]] = [this.order[newIdx]!, this.order[idx]!];
74
77
  }
@@ -1,4 +1,7 @@
1
1
  import type { AgentStatus, AgentEvent } from "./contracts/agent";
2
+ import type { ReorderDelta } from "./server/session-order";
3
+
4
+ export type { ReorderDelta };
2
5
 
3
6
  export const DEFAULT_SERVER_PORT = 4201;
4
7
  export const DEFAULT_SERVER_HOST = "127.0.0.1";
@@ -112,7 +115,7 @@ export type ClientCommand =
112
115
  | { type: "switch-index"; index: number }
113
116
  | { type: "new-session" }
114
117
  | { type: "kill-session"; name: string }
115
- | { type: "reorder-session"; name: string; delta: -1 | 1 | "top" | "bottom" }
118
+ | { type: "reorder-session"; name: string; delta: ReorderDelta }
116
119
  | { type: "refresh" }
117
120
  | { type: "move-focus"; delta: -1 | 1 }
118
121
  | { type: "focus-session"; name: string }
@@ -0,0 +1,4 @@
1
+ /** Truncate a string to `max` chars, appending an ellipsis character if clipped. */
2
+ export function truncate(s: string, max: number): string {
3
+ return s.length > max ? s.slice(0, max - 1) + "…" : s;
4
+ }