pi-message-sidebar 1.5.0 → 2.0.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 CHANGED
@@ -1,5 +1,92 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.0
4
+
5
+ > The obsidian rail: a deep canvas, one raised goal card, ghost chrome, and a motion language that only moves when the session does.
6
+
7
+ - **Palette and depth.** The rail is a truecolor ladder: a deep well (`#0c0d14`) as canvas, a raised panel step for the goal card, an accent-tinted selection, and two ghost tiers for chrome. 256-color and light-terminal variants throughout. The old flat 234/235/233 slab and the theme's raw selection color are gone.
8
+ - **Ghost headers.** Section rules are gone. SESSION, FILES, MESSAGES and RUNTIME are quiet left-aligned labels with right-aligned metadata, each preceded by an air row; sections separate by whitespace and background steps.
9
+ - **Goal card.** The hero block: breathing status dot (2.4s), budget share on the right, bold objective across up to three lines, and a `bdg`-labeled twelve-cell meter in eighths-of-a-cell resolution. A missing goal is one ghost row on the deep canvas.
10
+ - **Message slots.** Ordinals are gone; their four cells buy summary text (32 cells per line). Selection is a two-row yellow bar plus a soft accent tint; pending summaries pulse; landed summaries sweep accent then glow-decay over the canvas; age fade keeps newest bright and old muted.
11
+ - **Motion language.** Eased meters (context and budget glide toward their targets), a shimmer cell riding a meter while it eases, left-to-right reveal as a message arrives, a victory flash when a goal completes, and a sheen band crossing the flag while the rail is live. The tick runs only while something moves, plus the ambient goal breath; idle costs nothing.
12
+ - **The Fornace flag.** The rail's crown row is the seven brand hues sampled from the production logo, painted solid edge to edge.
13
+ - Pressure thresholds moved to 70/90 percent so ordinary sessions read accent, not alarm.
14
+
15
+ ## 1.11.0
16
+
17
+ > Plain-text summaries and an honest copy key: what you copy from the rail is finally clean.
18
+
19
+ - Stored summaries and fallback previews no longer contain ANSI resets: pi-tui's `truncateToWidth` wraps its ellipsis in `\x1b[0m` resets, and that helper was applied to plain text, so every clipped summary persisted and rendered with escape bytes (visible as garbage when copied, and resetting rail row colors mid-row). Both paths now strip ANSI after truncation; existing polluted summary caches self-heal on next session load.
20
+ - The focused rail's `c` key now copies the selected prompt's full text, matching the `[c] copy` hint. Previously `c` always copied the session file path even while the messages panel was focused and its hint advertised message copy. The unfocused rail keeps `c` as copy-session-path.
21
+
22
+ ## 1.10.0
23
+
24
+ > Embedded section headers, usage meters, and a tighter rail: every section now spends one row where it used to spend two.
25
+
26
+ - Section headers embed their label in the rule itself (`── SESSION ───────── main`) with right-aligned metadata: branch for SESSION, status for GOAL, position for MESSAGES, thinking level for RUNTIME, count for FILES. The old label-row-plus-separator pairs are gone; the minimum rail shrinks from 17 to 14 rows and the message viewport grows by three.
27
+ - Usage meters in the same language as the rules: `━━━━──────`. The goal budget row draws its meter with counts and elapsed time (compacting when crowded, drawing nothing for unlimited budgets), and the runtime context row meters context pressure with color thresholds: accent normally, warning at 60 percent, danger at 85. Goal states override: complete reads green, budget-limited or over-budget reads danger.
28
+ - A missing goal is now a single quiet rule row (`── no goal · /goal <objective> ──`) instead of a three-row block.
29
+ - Hint strips use bracketed keys: `[Ctrl+Shift+H] focus`, `[↑↓] select [↵] open [c] copy`, `[Esc] back [↑↓] scroll`.
30
+ - Budget allocation between sections is content-first: the session id row, then FILES rows, then goal breathing, then runtime breath, with everything else to the message viewport.
31
+
32
+ ## 1.9.0
33
+
34
+ > Theme-harmonized rail: universal content colors, a three-step background ladder, git-style file badges, age fade, and pi-recap motion.
35
+
36
+ - Colors now resolve from the active pi theme (accent, muted, dim, warning, diff and border tokens, `selectedBg` for the selection) with theme-agnostic universal grays for content text, the way pi-recap keeps recap bodies readable under any theme. The old hardcoded 256-color slabs are gone: backgrounds are a three-step ladder (base, raised for goal and detail, sunken for the hint strip) instead of contrasting blocks.
37
+ - Fixed a real leak: bare `\x1b[0m` resets inside composed rows (from pi-tui's truncation ellipsis and segment joins) painted the terminal's default foreground mid-row. All truncation now happens on plain text before coloring, and a regression test asserts no row leaks the default foreground.
38
+ - FILES rows carry the git letter convention shared by git, VS Code, and GitHub: M modified, A added, U untracked, D deleted, R renamed, colored through theme diff/warning tokens, fed by a throttled `git status --porcelain` provider that is symlink-safe, spans all repositories edited in the session (not just the session cwd), and stays silent outside repositories.
39
+ - Empty goal state rests on the neutral base background instead of claiming the raised step; the raised ground is reserved for live objectives with active budgets.
40
+ - Message streams shorter than the viewport bottom-anchor directly above the hint strip, letting spare space collect under the heading like a terminal chat stream rather than opening a dead gap above the hint.
41
+ - Message summaries fade with age like pi-recap recaps: newest bright, recent normal, older muted, deterministic previews dimmer still.
42
+ - Motion in the pi-recap spirit, ticking at 90 ms only while something moves: a truecolor pulsing dot (sine lerp, 256-color fallback) marks messages whose summary is still in flight, and a landing summary sweeps accent then bold accent over 360 ms before settling into its age color.
43
+ - The message detail header reports the message size: `#N HH:MM` left, `1.4k chars · 12 lines` right.
44
+ - Light terminals get an inverted background ladder and content grays via COLORFGBG, matching pi-recap's detection.
45
+ - `PI_SIDEBAR_SUMMARY_MODEL` overrides the summary route; a gateway microbench (fornace-flash 739 ms TTFB vs 1.1-3.1 s for the alternates) keeps `fornace-flash` as the default.
46
+
47
+ ## 1.8.0
48
+
49
+ > AI message summaries, a structured two-row message grid with hidden-count ellipsis rows, and an edited-files section.
50
+
51
+ - Messages are now AI summaries: `SummaryService` asks `fornace-flash` for one plain sentence (at most 12 words, essential names preserved) per prompt, stores up to 58 cells so a summary fills two rail rows, and keeps the deterministic preview as the placeholder until the model answers. Previews render dimmed while no model summary exists. The cache lives in `sidebar-summaries` with `PI_SUMMARIES_DIR` as its override.
52
+ - An unconfigured gateway now shows a setup hint (`AI summaries need FORNACE_LLM_API_KEY`) under the MESSAGES heading instead of staying fully silent; it never costs a message slot and never warns as a failure.
53
+ - The message list is a structured grid instead of a text blob: every message renders as a two-row slot with a selection marker, right-aligned ordinal, timestamp, and the summary wrapped across two 29-cell lines.
54
+ - When the history outgrows the viewport, faint ellipsis rows count the hidden messages (`… 11 earlier`, `… 2 later`); a message pair outranks the second ellipsis row when both cannot fit.
55
+ - New FILES section summarizes the session's write footprint between SESSION and MESSAGES: the heading counts distinct files, the rows list the most recently written ones (latest first, front-trimmed paths, repeat counts) from `edit`/`write`/`fast_write` tool calls. The section yields its space to the goal block and the message viewport on short terminals and does not render at all without edits.
56
+ - Message rendering moved into `src/messages.ts` (`MessagePanel`), which also owns the detail view; `sidebar-component.ts` returns below 400 lines, restoring the README claim.
57
+ - The message section's mandatory budget grows to four rows (heading, one two-row message, hint), so a viable rail always shows a full message slot.
58
+
59
+ ## 1.7.0
60
+
61
+ > Goal-first rail with model-written message titles. Replaces the bottom status dock with stacked sections.
62
+
63
+ - Message rows now show a short generated title instead of the raw prompt: `TitleService` calls `fornace-flash` through the Fornace gateway, caches per session on disk, and falls back to a deterministic first-words title when the gateway is unavailable or unconfigured. Titles never generate on the render path. Title generation that fails while the gateway is configured reports once per session instead of silently degrading, and a replaced service is disposed on session restart.
64
+ - Each message now actually receives its one refinement pass on a later turn; the previous cadence only ever inspected the newest message, so refinement never fired in normal use.
65
+ - Rail is now GOAL / SESSION / MESSAGES / runtime, top to bottom, replacing the bottom status dock.
66
+ - Fixed the crash on any populated render: the extension never passed `getTitle`, so `SidebarComponent.renderMessageRow` threw `TypeError: this.options.getTitle is not a function` and took the TUI down.
67
+ - Rail rows paint all 42 columns; they previously painted 40 and left a two-column seam down the rail.
68
+ - Right-aligned rail elements (elapsed time, position counter) sit flush against the rail edge; they were three cells short after the geometry fix. Detail text uses the full 39-column content width.
69
+ - Row allocation reserves every section's mandatory rows before distributing optional ones, so the message row, the `/goal` guidance line, and the branch · session row can no longer be silently sliced away. Below the mandatory budget the rail shows a bounded resize notice instead of clipped sections.
70
+ - Sections fill their exact row budgets and render with no masking slices: a message vanishing under an open detail (compaction, branch switch), an empty history, and a one-row resize notice all render complete sections instead of crashing the TUI.
71
+ - The detail scroll indicator reports a truthful range like `34-46 of 46` instead of an inaccurate `…0 more lines`, and the detail hint offers scrolling (`Esc back · ↑↓ scroll`) only when content actually overflows the body, otherwise just `Esc back`.
72
+ - Message ordinals count only the messages the rail displays, so the detail header `#N` stays in agreement with the heading `N/total` after image-only or whitespace-only turns.
73
+ - A missing `FORNACE_LLM_API_KEY` is treated as an unconfigured gateway (deterministic fallback titles) rather than a generation failure, so the session no longer warns spuriously.
74
+ - A slot narrower than 42 columns renders the width-bounded notice rather than overflowing its column.
75
+ - Non-home working directories keep their identity: `/private/tmp/x` no longer collapses to `x`, and long paths ellipsize from the front.
76
+ - Escape while the rail is focused closes an open message detail first and only unfocuses on the second press.
77
+
78
+ ## 1.6.0
79
+
80
+ > Readability pass driven by a live populated PTY audit: less cropping, more signal per row.
81
+
82
+ - Collapsed message previews now use up to two wrapped lines instead of one clipped line.
83
+ - Single header row: `Messages 5/5 · #5 01:26` replaces the two-row header.
84
+ - Goal recap shows the objective on its own line(s) again, with a second line when dock space allows.
85
+ - cmux row drops the label and leads with the surface ref (`surface:38 · title`), so operational identity never truncates away.
86
+ - Status dock is priority-ordered (identity, goal status, objective, runtime, hint) and degrades to goal-status-only before dropping the goal.
87
+ - Dock budget raised to five rows at normal heights; tiny-height behavior unchanged.
88
+
89
+
3
90
  ## 1.5.0
4
91
 
5
92
  - Replaced selected/newest pinning with a row-aware contiguous chronological viewport.
package/README.md CHANGED
@@ -4,25 +4,20 @@ Persistent message history sidebar for [Pi](https://pi.dev).
4
4
 
5
5
  ## Features
6
6
 
7
- - Fixed 42-column panel on the right
8
- - Two-row message header with position and selected-message metadata
9
- - Contiguous chronological message viewport that top-aligns short histories and follows new messages until you browse away
10
- - Compact status dock of at most four rows, including an optional two-line goal
7
+ - The obsidian rail: a 42-column truecolor ladder with a deep well as canvas, a raised panel step for the goal card, accent-tinted selection, and two ghost tiers for chrome; 256-color and light-terminal variants included
8
+ - The Fornace flag crowns the rail: the seven brand hues from the production logo, painted solid edge to edge, with a sheen band while the rail is live
9
+ - Goal card as the hero: breathing status dot, budget share, bold objective across up to three lines, and a `bdg`-labeled meter in eighths-of-a-cell resolution that eases toward the live ratio
10
+ - Ghost chrome: section labels sit flush left in a near-invisible tier with right-aligned metadata and an air row above; no rules, no dashes
11
+ - Structured two-row message slots without ordinals: time, then the summary across 32-cell lines; selection is a two-row yellow bar on a soft accent tint
12
+ - Motion language that idles for free: pulsing pending dots, landing sweeps, decaying arrival glows, left-to-right reveals, eased meters with a riding shimmer, and a victory flash when a goal completes
13
+ - AI message summaries (`fornace-flash` through the Fornace gateway, `PI_SIDEBAR_SUMMARY_MODEL` to override): one plain sentence per prompt, cached per session on disk, with a deterministic preview until the model answers; an unconfigured gateway shows a setup hint
14
+ - Age-faded summaries like pi-recap: newest bright, recent normal, older muted, previews dimmer still
15
+ - Git-style change badges (M/A/U/D/R) on the FILES rows, from a throttled `git status` provider
16
+ - Message detail view with size in chars and wrapped lines, scrollable when the text overflows
17
+ - Ellipsis rows counting hidden messages whenever the history outgrows the viewport
18
+ - Contiguous chronological viewport that bottom-anchors the stream and follows new messages until you browse away
19
+ - Mandatory rows reserved before optional rows are handed out; too short a terminal shows a resize notice rather than clipped sections
11
20
  - cmux session context that preserves the complete surface ref by truncating the workspace title first
12
- - Main transcript and editor render in their own reserved width
13
- - Persistent native right rail in fullscreen TUI mode
14
- - Compact regular-mode compositor in terminal-owned scrollback mode
15
- - Automatic collapse when the terminal cannot keep an 80-column main pane
16
- - `Ctrl+Shift+H` focuses the sidebar
17
- - Arrow keys navigate messages
18
- - `Enter` expands or collapses a message
19
- - `c` copies the session path
20
- - `Escape` returns focus to Pi
21
- - Width assertions cover every rendered sidebar line
22
-
23
- ## Requirements
24
-
25
- Pi 0.84.4 or newer. This extension uses the renderer-switching and fullscreen layout APIs shipped with the 0.84 series.
26
21
 
27
22
  ## Installation
28
23
 
@@ -50,16 +45,24 @@ The message body is one contiguous chronological viewport. New messages remain s
50
45
  - Press `Ctrl+Shift+H` to focus or unfocus the sidebar.
51
46
  - Press `↑` or `↓` to navigate.
52
47
  - Press `PageUp`, `PageDown`, `Home`, or `End` for larger jumps.
53
- - Press `Enter` to expand or collapse the selected message.
54
- - Press `c` to copy the current session path.
55
- - Press `Escape` to return focus to Pi.
48
+ - Press `Enter` to open the selected message's full text; scroll with `↑`/`↓` when the hint offers it.
49
+ - Press `c` to copy the selected prompt while the rail is focused, or the session path when it is not.
50
+ - Press `Escape` to close an open message, and again to return focus to Pi.
56
51
 
57
52
  ## Architecture
58
53
 
59
54
  - `index.ts` is the auto-discovered extension entrypoint.
60
55
  - `src/layout.ts` reserves a persistent horizontal region in fullscreen mode and composes the current screenful in regular mode.
61
- - `src/sidebar-component.ts` owns ID-stable navigation, expansion, follow-tail behavior, and the row-aware contiguous viewport.
62
- - `src/status-dock.ts` renders a compact, height-bounded goal, runtime, cmux, and validated status summary.
56
+ - `src/sidebar-component.ts` owns section budgets, the render cache, and focus handling.
57
+ - `src/messages.ts` renders the message grid and detail view, and owns ID-stable navigation and follow-tail behavior.
58
+ - `src/goal-card.ts` renders the goal card; `src/sections.ts` renders ghost headers plus the session, files, and runtime sections into fixed row budgets.
59
+ - `src/slots.ts` renders message slots; `src/flag.ts` paints the Fornace crown; `src/palette.ts` and `src/anim.ts` own the color ladder and the motion timing.
60
+ - `src/summaries.ts` generates and caches one-line AI summaries off the render path.
61
+ - `src/files.ts` collects the session's edited files from write tool calls.
62
+ - `src/git-status.ts` maps the worktree status onto the shared M/A/U/D/R letter convention.
63
+ - `src/palette.ts` resolves theme tokens, universal content grays, and the background ladder per render.
64
+ - `src/anim.ts` owns the pulse and settle timing; the tick runs only while something moves.
65
+ - `src/status-dock.ts` supplies context-usage and status validation helpers.
63
66
  - `src/goal.ts` reconstructs the active pi-codex-goal from session entries.
64
67
  - `src/cmux.ts` resolves the cmux workspace title and surface ref once per session.
65
68
  - `src/style.ts` provides ANSI-safe row filling and width helpers.
@@ -8,8 +8,13 @@ import { isViewportTUI, matchesKey, truncateToWidth } from "@earendil-works/pi-t
8
8
  import type { CmuxContext } from "./src/cmux.ts";
9
9
  import { resolveCmuxContext } from "./src/cmux.ts";
10
10
  import { isSidebarVisible } from "./src/constants.ts";
11
+ import { readSessionFileEdits } from "./src/files.ts";
12
+ import { GitStatusProvider } from "./src/git-status.ts";
11
13
  import { SidebarLayoutBridge } from "./src/layout.ts";
12
- import { SidebarComponent, type UserMessage } from "./src/sidebar-component.ts";
14
+ import { SidebarComponent } from "./src/sidebar-component.ts";
15
+ import { isGatewayConfigured, SummaryService, fallbackSummary } from "./src/summaries.ts";
16
+ import { stripControl } from "./src/style.ts";
17
+ import type { UserMessage } from "./src/types.ts";
13
18
 
14
19
  function extractUserText(message: { content: unknown }): string {
15
20
  if (typeof message.content === "string") return message.content;
@@ -24,12 +29,12 @@ function extractUserText(message: { content: unknown }): string {
24
29
 
25
30
  export function collectUserMessages(ctx: ExtensionContext): UserMessage[] {
26
31
  const messages: UserMessage[] = [];
27
- let index = 0;
28
32
  for (const entry of ctx.sessionManager.getBranch()) {
29
33
  if (entry.type !== "message" || entry.message.role !== "user") continue;
30
- index++;
31
- const text = extractUserText(entry.message).trim();
32
- if (text) messages.push({ id: entry.id, text, index, timestamp: entry.timestamp });
34
+ const text = stripControl(extractUserText(entry.message)).trim();
35
+ // A user turn with no text (image-only, say) is not shown, so it must not
36
+ // consume an ordinal: the detail's "#N" has to match the heading's "N/total".
37
+ if (text) messages.push({ id: entry.id, text, index: messages.length + 1, timestamp: entry.timestamp });
33
38
  }
34
39
  return messages;
35
40
  }
@@ -64,6 +69,8 @@ export default function messageSidebar(pi: ExtensionAPI): void {
64
69
  let cachedContext: ExtensionContext | null = null;
65
70
  let footerData: ReadonlyFooterDataProvider | null = null;
66
71
  let cmuxContext: CmuxContext | null = null;
72
+ let summaries: SummaryService | null = null;
73
+ const gitStatus = new GitStatusProvider();
67
74
  let refreshQueued = false;
68
75
 
69
76
  const scheduleRefresh = (ctx: ExtensionContext | null = cachedContext) => {
@@ -94,6 +101,15 @@ export default function messageSidebar(pi: ExtensionAPI): void {
94
101
  pi.on("session_start", (_event, ctx) => {
95
102
  if (ctx.mode !== "tui") return;
96
103
  cachedContext = ctx;
104
+ summaries?.dispose();
105
+ summaries = new SummaryService(
106
+ ctx.sessionManager.getSessionId(),
107
+ () => scheduleRefresh(ctx),
108
+ undefined,
109
+ (err) => ctx.ui.notify(err, "warning"),
110
+ );
111
+ summaries.seed(collectUserMessages(ctx));
112
+ void gitStatus.refresh(ctx.sessionManager.getCwd(), true, readSessionFileEdits(ctx).map((file) => file.path));
97
113
  void resolveCmuxContext().then((resolved) => {
98
114
  cmuxContext = resolved;
99
115
  scheduleRefresh(ctx);
@@ -108,6 +124,13 @@ export default function messageSidebar(pi: ExtensionAPI): void {
108
124
  getFooterData: () => footerData,
109
125
  getThinkingLevel: () => pi.getThinkingLevel(),
110
126
  getCmuxContext: () => cmuxContext,
127
+ getTheme: () => ctx.ui.theme,
128
+ getSummary: (messageId, text) => summaries?.get(messageId, text) ?? fallbackSummary(text),
129
+ hasSummary: (messageId) => summaries?.hasSummary(messageId) ?? false,
130
+ isPending: (messageId) => summaries?.isPending(messageId) ?? false,
131
+ summariesConfigured: isGatewayConfigured,
132
+ getEditedFiles: () => readSessionFileEdits(ctx),
133
+ getGitStatus: (path) => gitStatus.statusFor(path),
111
134
  messages: collectUserMessages(ctx),
112
135
  });
113
136
  return new SidebarLayoutBridge(currentTui, sidebar);
@@ -133,12 +156,9 @@ export default function messageSidebar(pi: ExtensionAPI): void {
133
156
  return undefined;
134
157
  }
135
158
  if (tui && isViewportTUI(tui)) return undefined;
136
- if (matchesKey(data, "escape") && sidebar?.isFocused()) {
137
- sidebar.setFocused(false);
138
- tui?.requestRender();
139
- return { consume: true };
140
- }
141
159
  if (sidebar?.isFocused()) {
160
+ // The component owns every key while focused, Escape included: it
161
+ // closes an open message detail first and unfocuses on the second press.
142
162
  sidebar.handleInput(data);
143
163
  return { consume: true };
144
164
  }
@@ -147,6 +167,9 @@ export default function messageSidebar(pi: ExtensionAPI): void {
147
167
  });
148
168
 
149
169
  pi.on("session_shutdown", () => {
170
+ sidebar?.stopAnimations();
171
+ summaries?.dispose();
172
+ summaries = null;
150
173
  sidebar = null;
151
174
  tui = null;
152
175
  cachedContext = null;
@@ -165,8 +188,15 @@ export default function messageSidebar(pi: ExtensionAPI): void {
165
188
  });
166
189
 
167
190
  pi.on("message_end", (_event, ctx) => scheduleRefresh(ctx));
168
- pi.on("turn_end", (_event, ctx) => scheduleRefresh(ctx));
169
- pi.on("agent_end", (_event, ctx) => scheduleRefresh(ctx));
191
+ pi.on("turn_end", (_event, ctx) => {
192
+ summaries?.turnCompleted(collectUserMessages(ctx));
193
+ void gitStatus.refresh(ctx.sessionManager.getCwd(), false, readSessionFileEdits(ctx).map((file) => file.path));
194
+ scheduleRefresh(ctx);
195
+ });
196
+ pi.on("agent_end", (_event, ctx) => {
197
+ void gitStatus.refresh(ctx.sessionManager.getCwd(), false, readSessionFileEdits(ctx).map((file) => file.path));
198
+ scheduleRefresh(ctx);
199
+ });
170
200
  pi.on("agent_settled", (_event, ctx) => scheduleRefresh(ctx));
171
201
  pi.on("model_select", (_event, ctx) => scheduleRefresh(ctx));
172
202
  pi.on("thinking_level_select", (_event, ctx) => scheduleRefresh(ctx));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-message-sidebar",
3
- "version": "1.5.0",
3
+ "version": "2.0.0",
4
4
  "description": "Persistent responsive message history sidebar for Pi",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/anim.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Animation timing for the rail, in the spirit of pi-recap: an 80-90 ms tick
3
+ * runs only while something is actually moving, and idle costs nothing.
4
+ * Two cadences carry the whole rail: a fast pulse for live work and a slow
5
+ * breath for ambient state, so motion reads as one language.
6
+ */
7
+
8
+ /** One sine cycle of the pulsing pending dot. */
9
+ export const DOT_CYCLE_MS = 900;
10
+ /** Ambient cycle of the goal status dot: present, not urgent. */
11
+ export const BREATHE_CYCLE_MS = 2400;
12
+ /** Color sweep after a summary lands: accent, bold accent, then rest. */
13
+ export const SETTLE_MS = 360;
14
+ /** How long a fresh message keeps its soft landing glow. */
15
+ export const GLOW_MS = 2200;
16
+ /** Left-to-right reveal of a message slot as it arrives. */
17
+ export const ARRIVE_MS = 350;
18
+ /** One sheen band crossing the flag while the rail is live. */
19
+ export const SHEEN_CYCLE_MS = 2800;
20
+ /** The goal-complete flash on the card title. */
21
+ export const VICTORY_MS = 700;
22
+ /** Tick cadence while any animation is live. */
23
+ export const ANIM_TICK_MS = 90;
24
+ /** Per-tick share of the gap a meter closes while easing toward its target. */
25
+ export const METER_EASE = 0.35;
26
+ /** Meters closer than this to their target are considered settled. */
27
+ export const METER_EPSILON = 0.004;
28
+ export const DOT_GLYPH = "●";
29
+
30
+ /** Cosine pulse 0..1, starting at the trough so a dot fades up, not blinks on. */
31
+ export function pulse(now: number, startedAt: number, cycleMs = DOT_CYCLE_MS): number {
32
+ const phase = ((now - startedAt) % cycleMs) / cycleMs;
33
+ return 0.5 - 0.5 * Math.cos(phase * 2 * Math.PI);
34
+ }
35
+
36
+ /** The ambient breath: same cosine, slower cycle. */
37
+ export function breathe(now: number): number {
38
+ return pulse(now, 0, BREATHE_CYCLE_MS);
39
+ }
40
+
41
+ /** 0 = settled, 1 = accent flash, 2 = bold accent. */
42
+ export function settlePhase(now: number, landedAt: number): 0 | 1 | 2 {
43
+ const elapsed = now - landedAt;
44
+ if (elapsed >= SETTLE_MS) return 0;
45
+ return elapsed < SETTLE_MS / 2 ? 1 : 2;
46
+ }
47
+
48
+ export function isSettling(now: number, landedAt: number): boolean {
49
+ return now - landedAt < SETTLE_MS;
50
+ }
51
+
52
+ /** 1 right after a message lands, decaying to 0 over GLOW_MS. */
53
+ export function glowStrength(now: number, landedAt: number): number {
54
+ const elapsed = now - landedAt;
55
+ if (elapsed >= GLOW_MS) return 0;
56
+ return 1 - elapsed / GLOW_MS;
57
+ }
58
+
59
+ /** 0..1 left-to-right reveal progress for a slot that arrived at `arrivedAt`. */
60
+ export function arriveProgress(now: number, arrivedAt: number): number {
61
+ const elapsed = now - arrivedAt;
62
+ if (elapsed >= ARRIVE_MS) return 1;
63
+ return Math.max(0, elapsed / ARRIVE_MS);
64
+ }
65
+
66
+ export function isArriving(now: number, arrivedAt: number): boolean {
67
+ return now - arrivedAt < ARRIVE_MS;
68
+ }
69
+
70
+ /** Index of the sheen band crossing `cells` flag cells, or -1 between sweeps. */
71
+ export function sheenBand(now: number, cells: number): number {
72
+ const phase = (now % SHEEN_CYCLE_MS) / SHEEN_CYCLE_MS;
73
+ const position = Math.floor(phase * (cells + 4)) - 2;
74
+ return position >= 0 && position < cells ? position : -1;
75
+ }
76
+
77
+ /** True while the goal-complete flash is still on the card. */
78
+ export function isVictory(now: number, completedAt: number): boolean {
79
+ return now - completedAt < VICTORY_MS;
80
+ }
81
+
82
+ /**
83
+ * A meter value that eases toward its target instead of snapping, so a jump
84
+ * in context pressure or budget spend reads as motion. Tick returns true
85
+ * while the value is still moving, which is what keeps the animation tick
86
+ * alive; a settled meter costs nothing.
87
+ */
88
+ export class EasedMeter {
89
+ private value: number | null = null;
90
+
91
+ set(target: number | null): void {
92
+ if (target === null || !Number.isFinite(target)) {
93
+ this.value = null;
94
+ return;
95
+ }
96
+ if (this.value === null) this.value = target;
97
+ }
98
+
99
+ get(): number | null {
100
+ return this.value;
101
+ }
102
+
103
+ target(target: number | null): number | null {
104
+ return target === null || !Number.isFinite(target) ? null : Math.max(0, Math.min(1, target));
105
+ }
106
+
107
+ /** True while a tick would still move the value toward `target`. */
108
+ moving(target: number | null): boolean {
109
+ const clamped = this.target(target);
110
+ if (clamped === null) return this.value !== null;
111
+ if (this.value === null) return false;
112
+ return Math.abs(clamped - this.value) >= METER_EPSILON;
113
+ }
114
+
115
+ /** Advance one tick; true while still converging toward `target`. */
116
+ tick(target: number | null): boolean {
117
+ const clamped = this.target(target);
118
+ if (clamped === null) {
119
+ this.value = null;
120
+ return false;
121
+ }
122
+ if (this.value === null) {
123
+ this.value = clamped;
124
+ return false;
125
+ }
126
+ const gap = clamped - this.value;
127
+ if (Math.abs(gap) < METER_EPSILON) {
128
+ this.value = clamped;
129
+ return false;
130
+ }
131
+ this.value += gap * METER_EASE;
132
+ return true;
133
+ }
134
+ }
package/src/files.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** One distinct file the session wrote to, with its edit count. */
4
+ export type FileEdit = { path: string; edits: number };
5
+
6
+ /** Tools whose primary effect is writing a file path. */
7
+ const WRITE_TOOLS = new Set(["edit", "write", "fast_write"]);
8
+
9
+ type BranchEntry = { type?: string; id?: string; timestamp?: string; message?: { role?: string; content?: unknown } };
10
+
11
+ /**
12
+ * Distinct files written by the session's edit/write tool calls, most
13
+ * recently touched first. Read-only tools and every other shape are skipped.
14
+ */
15
+ export function collectFileEdits(entries: Iterable<BranchEntry>): FileEdit[] {
16
+ const touched = new Map<string, { edits: number; last: number }>();
17
+ let sequence = 0;
18
+ for (const entry of entries) {
19
+ if (entry.type !== "message") continue;
20
+ const message = entry.message;
21
+ if (!message || message.role !== "assistant") continue;
22
+ const content = message.content;
23
+ if (!Array.isArray(content)) continue;
24
+ for (const part of content) {
25
+ if (!part || typeof part !== "object" || (part as { type?: unknown }).type !== "toolCall") continue;
26
+ const call = part as { name?: unknown; arguments?: unknown };
27
+ if (typeof call.name !== "string" || !WRITE_TOOLS.has(call.name)) continue;
28
+ const path = (call.arguments as { path?: unknown } | null | undefined)?.path;
29
+ if (typeof path !== "string" || !path) continue;
30
+ const record = touched.get(path);
31
+ if (record) {
32
+ record.edits++;
33
+ record.last = sequence;
34
+ } else {
35
+ touched.set(path, { edits: 1, last: sequence });
36
+ }
37
+ sequence++;
38
+ }
39
+ }
40
+ return [...touched.entries()]
41
+ .sort(([, a], [, b]) => b.last - a.last)
42
+ .map(([path, record]) => ({ path, edits: record.edits }));
43
+ }
44
+
45
+ type FileEditCache = { key: string; files: FileEdit[] };
46
+ const caches = new WeakMap<ExtensionContext, FileEditCache>();
47
+
48
+ /** Cached per extension context, like the goal reconstruction. */
49
+ export function readSessionFileEdits(ctx: ExtensionContext): FileEdit[] {
50
+ const branch = ctx.sessionManager.getBranch();
51
+ const last = branch.at(-1);
52
+ const key = `${branch.length}:${last?.id ?? ""}:${last?.timestamp ?? ""}`;
53
+ const previous = caches.get(ctx);
54
+ if (previous?.key === key) return previous.files;
55
+ const files = collectFileEdits(branch);
56
+ caches.set(ctx, { key, files });
57
+ return files;
58
+ }
package/src/flag.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { sheenBand } from "./anim.ts";
2
+ import { FORNACE_FLAG, FORNACE_FLAG_256, type Palette, type RGB, bgRgb, blend } from "./palette.ts";
3
+ import { RST } from "./style.ts";
4
+
5
+ /**
6
+ * The Fornace flag: the rail's crown row, a solid rainbow of the seven brand
7
+ * hues sampled from the production logo. While the rail is live a two-cell
8
+ * sheen band crosses it, so the mark breathes with the session and rests
9
+ * when the session rests.
10
+ */
11
+ export function flagRow(palette: Palette, cells: number, now: number, live: boolean): string {
12
+ const band = live ? sheenBand(now, cells) : -1;
13
+ const lit = (index: number) => band >= 0 && (index === band || index === band + 1);
14
+ const parts: string[] = [palette.edge, "│", RST];
15
+ for (let index = 0; index < cells; index++) {
16
+ const hue = index % FORNACE_FLAG.length;
17
+ let cell: string;
18
+ if (palette.truecolor) {
19
+ const rgb: RGB = FORNACE_FLAG[hue]!;
20
+ cell = bgRgb(lit(index) ? blend(rgb, [255, 255, 255], 0.45) : rgb);
21
+ } else {
22
+ cell = `\x1b[48;5;${FORNACE_FLAG_256[hue]}m`;
23
+ if (lit(index)) cell = `\x1b[1m${cell}`;
24
+ }
25
+ parts.push(cell, " ");
26
+ }
27
+ parts.push(RST);
28
+ return parts.join("");
29
+ }
@@ -0,0 +1,123 @@
1
+ import { execFile } from "node:child_process";
2
+ import { realpathSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ const run = promisify(execFile);
7
+
8
+ /** Distinct repositories queried per refresh; a session rarely edits across more. */
9
+ const MAX_ROOTS = 8;
10
+
11
+ /**
12
+ * Git worktree status per absolute path, in the letter convention shared by
13
+ * git, VS Code, and GitHub: M modified, A added, D deleted, R renamed,
14
+ * U untracked. Refresh is throttled and spans every repository the session
15
+ * edits in, not just the session cwd; failures leave the last good map.
16
+ */
17
+ export class GitStatusProvider {
18
+ private status = new Map<string, string>();
19
+ private readonly realpaths = new Map<string, string>();
20
+ private readonly roots = new Map<string, string>();
21
+ private lastRun = 0;
22
+ private inflight: Promise<void> | null = null;
23
+ lastError: string | null = null;
24
+
25
+ constructor(
26
+ private readonly refreshIntervalMs = 4000,
27
+ private readonly exec: (file: string, args: string[], cwd: string) => Promise<string> = async (file, args, cwd) => {
28
+ const { stdout } = await run(file, args, { cwd, timeout: 5000, maxBuffer: 16 * 1024 * 1024 });
29
+ return stdout;
30
+ },
31
+ ) {}
32
+
33
+ /** Throttled refresh; concurrent callers share one run. */
34
+ refresh(cwd: string, force = false, extraPaths: readonly string[] = []): Promise<void> {
35
+ if (this.inflight) return this.inflight;
36
+ const now = Date.now();
37
+ if (!force && now - this.lastRun < this.refreshIntervalMs) return Promise.resolve();
38
+ this.lastRun = now;
39
+ this.inflight = this.run(cwd, extraPaths).finally(() => { this.inflight = null; });
40
+ return this.inflight;
41
+ }
42
+
43
+ /** One letter per the shared convention, or null when git has no opinion. */
44
+ statusFor(path: string): string | null {
45
+ const xy = this.status.get(this.realpath(path));
46
+ if (!xy) return null;
47
+ if (xy === "??") return "U";
48
+ const index = xy[0] ?? " ";
49
+ const worktree = xy[1] ?? " ";
50
+ const letter = index !== " " && index !== "?" ? index : worktree;
51
+ return letter === "M" || letter === "A" || letter === "D" || letter === "R" ? letter : null;
52
+ }
53
+
54
+ /** Symlink-safe identity; callers hand us /var paths while git says /private/var. */
55
+ private realpath(path: string): string {
56
+ const cached = this.realpaths.get(path);
57
+ if (cached) return cached;
58
+ let resolved = path;
59
+ try {
60
+ resolved = realpathSync(path);
61
+ } catch {
62
+ // A path git wrote but the disk renamed: keep the raw string.
63
+ }
64
+ this.realpaths.set(path, resolved);
65
+ return resolved;
66
+ }
67
+
68
+ /** Repository root for a directory; failures stay uncached so they retry. */
69
+ private async gitRoot(dir: string): Promise<string | null> {
70
+ const cached = this.roots.get(dir);
71
+ if (cached) return cached;
72
+ try {
73
+ const root = (await this.exec("git", ["rev-parse", "--show-toplevel"], dir)).trim();
74
+ const resolved = this.realpath(root);
75
+ this.roots.set(dir, resolved);
76
+ return resolved;
77
+ } catch (error) {
78
+ this.lastError = error instanceof Error ? error.message : String(error);
79
+ return null;
80
+ }
81
+ }
82
+
83
+ private async run(cwd: string, extraPaths: readonly string[]): Promise<void> {
84
+ const roots = new Set<string>();
85
+ const cwdRoot = await this.gitRoot(cwd);
86
+ if (cwdRoot) roots.add(cwdRoot);
87
+ for (const path of extraPaths) {
88
+ if (roots.size >= MAX_ROOTS) break;
89
+ const root = await this.gitRoot(dirname(path));
90
+ if (root) roots.add(root);
91
+ }
92
+ if (roots.size === 0) return;
93
+
94
+ const next = new Map<string, string>();
95
+ const failures: string[] = [];
96
+ await Promise.all([...roots].map(async (root) => {
97
+ try {
98
+ const stdout = await this.exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], root);
99
+ for (const [path, xy] of parsePorcelain(stdout, root)) next.set(path, xy);
100
+ } catch (error) {
101
+ failures.push(error instanceof Error ? error.message : String(error));
102
+ }
103
+ }));
104
+ this.status = next;
105
+ this.lastError = failures.length > 0 ? failures.join("; ") : null;
106
+ }
107
+ }
108
+
109
+ /** -z records: "XY path\0" with an extra "origPath\0" record for renames. */
110
+ function parsePorcelain(stdout: string, root: string): [string, string][] {
111
+ const entries: [string, string][] = [];
112
+ const records = stdout.split("\0");
113
+ for (let index = 0; index < records.length; index++) {
114
+ const record = records[index];
115
+ if (!record || record.length < 4) continue;
116
+ const xy = record.slice(0, 2);
117
+ const rel = record.slice(3);
118
+ if (!rel) continue;
119
+ entries.push([join(root, rel), xy]);
120
+ if (xy[0] === "R" || xy[0] === "C") index++; // skip the source-path record
121
+ }
122
+ return entries;
123
+ }