pi-goosedump 0.7.2 → 0.8.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.
Files changed (3) hide show
  1. package/README.md +17 -1
  2. package/index.ts +399 -215
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # pi-goosedump
2
2
 
3
+ <img alt="pi-goosedump browser dialog" src="screenshot.png">
4
+
3
5
  Coding agent context data browser plugin for [pi](https://pi.dev/) using
4
6
  [`goosedump`](https://github.com/jarkkojs/goosedump).
5
7
 
@@ -73,7 +75,9 @@ goosedump({ action: "grep", pattern: "*rand*", outputFormat: "claude" })
73
75
 
74
76
  ### Command: `/goosedump`
75
77
 
76
- Opens an interactive session browser.
78
+ Opens an interactive dashboard. The sessions tab lists Pi sessions from the
79
+ current working directory only. The settings tab edits the global and project
80
+ `goosedump` settings sections.
77
81
 
78
82
  ### Compaction
79
83
 
@@ -82,6 +86,18 @@ current session to its goosedump id and runs `goosedump compact <id>` with Pi's
82
86
  compaction range (`--from`, `--until`, and `--scope`) so the generated summary
83
87
  matches the entries Pi is about to replace.
84
88
 
89
+ Optional goosedump counters can also trigger compaction. `0` disables a counter;
90
+ when either counter fires, both counters reset to their configured values.
91
+
92
+ ```json
93
+ {
94
+ "goosedump": {
95
+ "stepsBeforeCompact": 0,
96
+ "turnsBeforeCompact": 0
97
+ }
98
+ }
99
+ ```
100
+
85
101
  ## License
86
102
 
87
103
  `pi-goosedump` is licensed under `MIT`. See [LICENSE](LICENSE) for more
package/index.ts CHANGED
@@ -10,16 +10,18 @@ import type {
10
10
  SessionEntry,
11
11
  } from '@earendil-works/pi-coding-agent';
12
12
 
13
- import { defineTool } from '@earendil-works/pi-coding-agent';
13
+ import { defineTool, getSettingsListTheme } from '@earendil-works/pi-coding-agent';
14
14
 
15
15
  import { execFileSync } from 'node:child_process';
16
- import { existsSync } from 'node:fs';
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
17
  import { createRequire } from 'node:module';
18
18
  import { homedir } from 'node:os';
19
- import { basename, extname, isAbsolute, join, relative } from 'node:path';
19
+ import { basename, dirname, extname, isAbsolute, join, relative } from 'node:path';
20
20
 
21
21
  import {
22
22
  Key,
23
+ SettingsList,
24
+ type SettingItem,
23
25
  matchesKey,
24
26
  truncateToWidth,
25
27
  visibleWidth,
@@ -29,25 +31,24 @@ import { Type } from '@sinclair/typebox';
29
31
 
30
32
  const require = createRequire(import.meta.url);
31
33
 
32
- const GOOSEDUMP_VERSION = [0, 7, 4] as const;
34
+ const GOOSEDUMP_VERSION = [0, 8, 1] as const;
33
35
 
34
- // One element of goosedump 0.7's `list` JSON output.
36
+ // One element of goosedump 0.8's `list` JSON output.
35
37
  interface ListEntryJson {
36
38
  id: string;
37
39
  parent: string | null;
38
40
  provider: string;
39
- native_id: string;
40
41
  cwd: string;
41
42
  count: number;
42
43
  label: string | null;
43
44
  from: string | null;
44
45
  until: string | null;
46
+ native_id?: string;
45
47
  }
46
48
 
47
49
  interface GoosedumpListing {
48
50
  id: string;
49
51
  provider: string;
50
- nativeId: string;
51
52
  cwd: string;
52
53
  parent: string | null;
53
54
  label: string | null;
@@ -66,24 +67,26 @@ interface GoosedumpSearchResult {
66
67
  page: number;
67
68
  totalPages: number;
68
69
  }
69
-
70
- interface ToolCallJson {
71
- name: string;
72
- arguments: unknown;
73
- }
74
-
75
- interface ShowMessageJson {
76
- entryId: string;
77
- kind: 'text' | 'assistant' | 'toolResult' | 'bash';
78
- role?: string;
70
+ interface ShowPartJson {
71
+ type: 'text' | 'reasoning' | 'toolCall' | 'toolResult' | 'image' | 'bash' | 'passthrough';
79
72
  text?: string;
80
- thinking?: string[];
81
- toolCalls?: ToolCallJson[];
73
+ name?: string;
74
+ arguments?: unknown;
82
75
  toolName?: string;
83
76
  content?: string;
84
77
  isError?: boolean;
78
+ mimeType?: string;
79
+ ocrText?: string;
85
80
  command?: string;
86
81
  output?: string;
82
+ kind?: string;
83
+ raw?: unknown;
84
+ }
85
+
86
+ interface ShowMessageJson {
87
+ entryId: string;
88
+ role: string;
89
+ parts: ShowPartJson[];
87
90
  }
88
91
 
89
92
  interface ShowJson {
@@ -115,6 +118,93 @@ interface CompactJson {
115
118
  brief: string;
116
119
  }
117
120
 
121
+ interface GoosedumpSettings {
122
+ stepsBeforeCompact: number;
123
+ turnsBeforeCompact: number;
124
+ }
125
+
126
+ type SettingsScope = 'global' | 'project';
127
+ type GoosedumpSettingKey = keyof GoosedumpSettings;
128
+
129
+ const DEFAULT_GOOSEDUMP_SETTINGS: GoosedumpSettings = {
130
+ stepsBeforeCompact: 0,
131
+ turnsBeforeCompact: 0,
132
+ };
133
+
134
+ const COMPACT_SETTING_VALUES = ['0', '5', '10', '20', '50', '100'];
135
+
136
+ function piAgentDir(): string {
137
+ return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
138
+ }
139
+
140
+ function globalSettingsPath(): string {
141
+ return join(piAgentDir(), 'settings.json');
142
+ }
143
+
144
+ function projectSettingsPath(cwd: string): string {
145
+ return join(cwd, '.pi', 'settings.json');
146
+ }
147
+
148
+ function isRecord(value: unknown): value is Record<string, unknown> {
149
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
150
+ }
151
+
152
+ function readJsonObject(path: string): Record<string, unknown> {
153
+ if (!existsSync(path)) return {};
154
+
155
+ try {
156
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown;
157
+ return isRecord(parsed) ? parsed : {};
158
+ } catch {
159
+ return {};
160
+ }
161
+ }
162
+
163
+ function readGoosedumpSettingsFrom(path: string): Partial<GoosedumpSettings> {
164
+ const raw = readJsonObject(path).goosedump;
165
+ if (!isRecord(raw)) return {};
166
+
167
+ const settings: Partial<GoosedumpSettings> = {};
168
+ for (const key of ['stepsBeforeCompact', 'turnsBeforeCompact'] as const) {
169
+ const value = raw[key];
170
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) {
171
+ settings[key] = value;
172
+ }
173
+ }
174
+ return settings;
175
+ }
176
+
177
+ function resolveGoosedumpSettings(cwd: string): GoosedumpSettings {
178
+ return {
179
+ ...DEFAULT_GOOSEDUMP_SETTINGS,
180
+ ...readGoosedumpSettingsFrom(globalSettingsPath()),
181
+ ...readGoosedumpSettingsFrom(projectSettingsPath(cwd)),
182
+ };
183
+ }
184
+
185
+ function readScopedGoosedumpSettings(cwd: string, scope: SettingsScope): GoosedumpSettings {
186
+ const path = scope === 'global' ? globalSettingsPath() : projectSettingsPath(cwd);
187
+ return { ...DEFAULT_GOOSEDUMP_SETTINGS, ...readGoosedumpSettingsFrom(path) };
188
+ }
189
+
190
+ function writeScopedGoosedumpSetting(
191
+ cwd: string,
192
+ scope: SettingsScope,
193
+ key: GoosedumpSettingKey,
194
+ value: number,
195
+ ): void {
196
+ const path = scope === 'global' ? globalSettingsPath() : projectSettingsPath(cwd);
197
+ const root = readJsonObject(path);
198
+ const goosedump = isRecord(root.goosedump) ? root.goosedump : {};
199
+ root.goosedump = { ...goosedump, [key]: value };
200
+ mkdirSync(dirname(path), { recursive: true });
201
+ writeFileSync(path, `${JSON.stringify(root, null, 2)}\n`);
202
+ }
203
+
204
+ function formatCompactSetting(value: number): string {
205
+ return value === 0 ? 'disabled' : String(value);
206
+ }
207
+
118
208
  function resolveGoosedumpBinary(): string {
119
209
  try {
120
210
  return require.resolve('@jarkkojs/goosedump/bin/goosedump.js');
@@ -181,51 +271,43 @@ function summarizeToolArgs(args: unknown): string {
181
271
  return clip(JSON.stringify(args), 160);
182
272
  }
183
273
 
184
- function showMessageRole(m: ShowMessageJson): string {
185
- switch (m.kind) {
186
- case 'assistant':
187
- return 'assistant';
188
- case 'toolResult':
189
- return `tool/${m.toolName ?? ''}${m.isError ? ' ⚠' : ''}`;
190
- case 'bash':
191
- return 'bash';
192
- default:
193
- return m.role && m.role.length > 0 ? m.role : 'unknown';
194
- }
195
- }
196
-
197
- function showMessageContent(m: ShowMessageJson): string {
198
- switch (m.kind) {
199
- case 'assistant': {
200
- const parts: string[] = [];
201
- if (m.thinking && m.thinking.length > 0) parts.push(m.thinking.join('\n'));
202
- if (m.text) parts.push(m.text);
203
- if (m.toolCalls && m.toolCalls.length > 0) {
204
- parts.push(
205
- m.toolCalls
206
- .map((tc) => {
207
- const summary = summarizeToolArgs(tc.arguments);
208
- return summary ? `${tc.name}(${summary})` : tc.name;
209
- })
210
- .join('\n'),
211
- );
212
- }
213
- return parts.join('\n');
274
+ function showPartContent(part: ShowPartJson): string {
275
+ switch (part.type) {
276
+ case 'text':
277
+ case 'reasoning':
278
+ return part.text ?? '';
279
+ case 'toolCall': {
280
+ const summary = summarizeToolArgs(part.arguments);
281
+ return summary ? `${part.name ?? 'tool'}(${summary})` : (part.name ?? 'tool');
282
+ }
283
+ case 'toolResult': {
284
+ const name = part.toolName ?? 'tool';
285
+ const header = `${name}${part.isError ? ' ⚠' : ''}`;
286
+ return part.content ? `${header}\n${part.content}` : header;
287
+ }
288
+ case 'image': {
289
+ const header = `[image ${part.mimeType ?? 'unknown'}]`;
290
+ return part.ocrText ? `${header}\n${part.ocrText}` : header;
214
291
  }
215
- case 'toolResult':
216
- return m.content ?? '';
217
292
  case 'bash': {
218
- const cmd = m.command ? `$ ${m.command}` : '';
219
- if (cmd && m.output) return `${cmd}\n${m.output}`;
220
- return cmd || (m.output ?? '');
293
+ const cmd = part.command ? `$ ${part.command}` : '';
294
+ if (cmd && part.output) return `${cmd}\n${part.output}`;
295
+ return cmd || (part.output ?? '');
296
+ }
297
+ case 'passthrough': {
298
+ const kind = part.kind ? `passthrough:${part.kind}` : 'passthrough';
299
+ const raw = part.raw === undefined ? '' : JSON.stringify(part.raw);
300
+ return raw ? `[${kind}] ${raw}` : `[${kind}]`;
221
301
  }
222
- default:
223
- return m.text ?? '';
224
302
  }
225
303
  }
226
304
 
305
+ function showMessageContent(m: ShowMessageJson): string {
306
+ return m.parts.map(showPartContent).filter(Boolean).join('\n');
307
+ }
308
+
227
309
  function showMessage(m: ShowMessageJson): GoosedumpMessage {
228
- return { id: m.entryId, role: showMessageRole(m), content: showMessageContent(m) };
310
+ return { id: m.entryId, role: m.role, content: showMessageContent(m) };
229
311
  }
230
312
 
231
313
  function hitMessage(h: HitJson): GoosedumpMessage {
@@ -269,16 +351,18 @@ function goosedumpList(provider = 'pi'): GoosedumpListing[] {
269
351
  const entries = JSON.parse(runGoosedump(['list'])) as ListEntryJson[];
270
352
  return entries
271
353
  .filter((entry) => entry.provider === provider)
272
- .map((entry) => ({
273
- id: entry.id,
274
- provider: entry.provider,
275
- nativeId: entry.native_id,
276
- cwd: entry.cwd,
277
- parent: entry.parent,
278
- label: entry.label ?? null,
279
- // Only pi sessions map back to a local file we can resume.
280
- path: provider === 'pi' ? piSessionFilePath(entry.native_id) : null,
281
- }));
354
+ .map((entry) => {
355
+ const nativeId = entry.native_id ?? entry.id;
356
+ return {
357
+ id: entry.id,
358
+ provider: entry.provider,
359
+ cwd: entry.cwd,
360
+ parent: entry.parent,
361
+ label: entry.label ?? null,
362
+ // Only pi sessions map back to a local file we can resume.
363
+ path: provider === 'pi' ? piSessionFilePath(nativeId) : null,
364
+ };
365
+ });
282
366
  }
283
367
 
284
368
  function piSessionsDir(): string {
@@ -475,7 +559,7 @@ function currentSessionContextId(ctx: ExtensionContext): string | null {
475
559
  const sessionFile = ctx.sessionManager.getSessionFile();
476
560
  if (!sessionFile) return null;
477
561
  const nativeId = sessionFileNativeId(sessionFile);
478
- const listing = goosedumpList('pi').find((entry) => entry.nativeId === nativeId);
562
+ const listing = goosedumpList('pi').find((entry) => entry.id === nativeId);
479
563
  return listing ? listing.id : null;
480
564
  }
481
565
 
@@ -669,61 +753,56 @@ async function runSessionBrowser(
669
753
  ): Promise<string | null | undefined> {
670
754
  let resumePath: string | null | undefined;
671
755
 
672
- const OVERLAY_WIDTH = 72;
673
-
674
- // Build tree from flat listings using parent relationships.
675
- let listingIds = new Set(listings.map((l) => l.id));
676
- let roots = listings.filter((l) => l.parent === null || !listingIds.has(l.parent));
677
- let childrenMap = new Map<string, GoosedumpListing[]>();
678
- for (const l of listings) {
679
- if (l.parent !== null) {
680
- const siblings = childrenMap.get(l.parent) ?? [];
681
- siblings.push(l);
682
- childrenMap.set(l.parent, siblings);
683
- }
756
+ const OVERLAY_WIDTH = 84;
757
+ const LIST_VIEWPORT = 18;
758
+ const COMPACT_VIEWPORT = 18;
759
+ const location = ctx.cwd;
760
+ const settingValues = COMPACT_SETTING_VALUES;
761
+
762
+ function locationListings(): GoosedumpListing[] {
763
+ return listings.filter((listing) => listing.cwd === location);
684
764
  }
685
765
 
686
- function rebuildTree(): void {
687
- listingIds = new Set(listings.map((l) => l.id));
688
- roots = listings.filter((l) => l.parent === null || !listingIds.has(l.parent));
689
- childrenMap = new Map<string, GoosedumpListing[]>();
690
- for (const l of listings) {
691
- if (l.parent !== null) {
692
- const siblings = childrenMap.get(l.parent) ?? [];
693
- siblings.push(l);
694
- childrenMap.set(l.parent, siblings);
695
- }
696
- }
766
+ function refreshListings(): void {
767
+ listings.splice(0, listings.length, ...goosedumpList());
697
768
  }
698
769
 
699
- function truncateCwd(cwd: string, maxLen: number): string {
700
- if (cwd.length <= maxLen) return cwd;
770
+ function shortPath(path: string, maxLen: number): string {
771
+ if (path.length <= maxLen) return path;
701
772
  const head = Math.floor(maxLen / 2) + 2;
702
773
  const tail = maxLen - head - 3;
703
- if (tail < 4) return cwd.slice(0, maxLen - 3) + '...';
704
- return cwd.slice(0, head) + '...' + cwd.slice(cwd.length - tail);
774
+ if (tail < 4) return `${path.slice(0, maxLen - 3)}...`;
775
+ return `${path.slice(0, head)}...${path.slice(path.length - tail)}`;
705
776
  }
706
777
 
707
- function flattenTree(
708
- nodes: GoosedumpListing[],
709
- expanded: Set<string>,
710
- depth: number,
711
- ): { listing: GoosedumpListing; depth: number; hasChildren: boolean }[] {
712
- const result: { listing: GoosedumpListing; depth: number; hasChildren: boolean }[] = [];
713
- for (const node of nodes) {
714
- const children = childrenMap.get(node.id) ?? [];
715
- result.push({ listing: node, depth, hasChildren: children.length > 0 });
716
- if (children.length > 0 && expanded.has(node.id)) {
717
- result.push(...flattenTree(children, expanded, depth + 1));
718
- }
719
- }
720
- return result;
778
+ function settingLabel(scope: SettingsScope, key: GoosedumpSettingKey): string {
779
+ const scopeLabel = scope === 'global' ? 'Global' : 'Project';
780
+ const keyLabel = key === 'stepsBeforeCompact' ? 'stepsBeforeCompact' : 'turnsBeforeCompact';
781
+ return `${scopeLabel} ${keyLabel}`;
782
+ }
783
+
784
+ function buildSettingItems(): SettingItem[] {
785
+ const global = readScopedGoosedumpSettings(location, 'global');
786
+ const project = readScopedGoosedumpSettings(location, 'project');
787
+ const settingsByScope = { global, project } satisfies Record<SettingsScope, GoosedumpSettings>;
788
+
789
+ return (['global', 'project'] as const).flatMap((scope) =>
790
+ (['stepsBeforeCompact', 'turnsBeforeCompact'] as const).map((key) => ({
791
+ id: `${scope}:${key}`,
792
+ label: settingLabel(scope, key),
793
+ description:
794
+ key === 'stepsBeforeCompact'
795
+ ? 'Tool steps before goosedump starts compaction; 0 disables this trigger.'
796
+ : 'LLM turns before goosedump starts compaction; 0 disables this trigger.',
797
+ currentValue: String(settingsByScope[scope][key]),
798
+ values: settingValues,
799
+ })),
800
+ );
721
801
  }
722
802
 
723
803
  await ctx.ui.custom<void>(
724
804
  (tui, theme, _kb, done) => {
725
- let expandedNodes = new Set<string>();
726
- let displayList = flattenTree(roots, expandedNodes, 0);
805
+ let tab: 'sessions' | 'settings' = 'sessions';
727
806
  let selectedIndex = 0;
728
807
  let listScroll = 0;
729
808
  let mode: 'list' | 'compact' = 'list';
@@ -731,27 +810,45 @@ async function runSessionBrowser(
731
810
  let compactScroll = 0;
732
811
  let compactTitle = '';
733
812
  let pendingDeletionId: string | null = null;
813
+ let settingsList: SettingsList;
734
814
 
735
- const COMPACT_VIEWPORT = 20;
736
- const LIST_VIEWPORT = 20;
737
815
  const wrapWidth = OVERLAY_WIDTH - 4;
738
816
 
739
- function rebuildDisplayList(): void {
740
- displayList = flattenTree(roots, expandedNodes, 0);
817
+ function resetSelection(): void {
818
+ const count = locationListings().length;
819
+ selectedIndex = Math.min(selectedIndex, Math.max(0, count - 1));
820
+ listScroll = Math.min(listScroll, Math.max(0, count - LIST_VIEWPORT));
821
+ }
822
+
823
+ function rebuildSettingsList(): void {
824
+ settingsList = new SettingsList(
825
+ buildSettingItems(),
826
+ 10,
827
+ getSettingsListTheme(),
828
+ (id, newValue) => {
829
+ const [scope, key] = id.split(':') as [SettingsScope, GoosedumpSettingKey];
830
+ const parsed = Number(newValue);
831
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return;
832
+ writeScopedGoosedumpSetting(location, scope, key, parsed);
833
+ settingsList.updateValue(id, newValue);
834
+ ctx.ui.notify(`${settingLabel(scope, key)} = ${formatCompactSetting(parsed)}`, 'info');
835
+ tui.requestRender();
836
+ },
837
+ () => {
838
+ tab = 'sessions';
839
+ tui.requestRender();
840
+ },
841
+ );
741
842
  }
742
843
 
844
+ rebuildSettingsList();
845
+
743
846
  function executeDeletion(): void {
744
847
  try {
745
848
  const id = pendingDeletionId!;
746
849
  goosedumpDelete(id);
747
- // Remove from listings and rebuild tree
748
- const idx = listings.findIndex((l) => l.id === id);
749
- if (idx >= 0) listings.splice(idx, 1);
750
- rebuildTree();
751
- rebuildDisplayList();
752
- if (selectedIndex >= displayList.length) {
753
- selectedIndex = Math.max(0, displayList.length - 1);
754
- }
850
+ refreshListings();
851
+ resetSelection();
755
852
  pendingDeletionId = null;
756
853
  } catch (err) {
757
854
  const msg = err instanceof Error ? err.message : String(err);
@@ -761,6 +858,54 @@ async function runSessionBrowser(
761
858
  tui.requestRender();
762
859
  }
763
860
 
861
+ function makeRow(content: string, innerW: number, border: string): string {
862
+ const line = truncateToWidth(content, innerW);
863
+ const pad = Math.max(0, innerW - visibleWidth(line));
864
+ return `${border} ${line}${' '.repeat(pad)} ${border}`;
865
+ }
866
+
867
+ function renderTabs(innerW: number, border: string): string {
868
+ const sessions = tab === 'sessions' ? theme.fg('accent', '[Sessions]') : '[Sessions]';
869
+ const settings = tab === 'settings' ? theme.fg('accent', '[Settings]') : '[Settings]';
870
+ return makeRow(`${sessions} ${settings}`, innerW, border);
871
+ }
872
+
873
+ function renderSettings(width: number, innerW: number, border: string): string[] {
874
+ const borderFg = (s: string) => theme.fg('border', s);
875
+ const accent = (s: string) => theme.fg('accent', s);
876
+ const dim = (s: string) => theme.fg('dim', s);
877
+ const lines: string[] = [];
878
+ const title = accent(' Goosedump Dashboard ');
879
+ const topFill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(title))));
880
+ lines.push(`${borderFg('╭─')}${title}${topFill}${borderFg('─╮')}`);
881
+ lines.push(renderTabs(innerW, border));
882
+ lines.push(
883
+ makeRow(
884
+ dim('Settings are written to ~/.pi/agent/settings.json and .pi/settings.json.'),
885
+ innerW,
886
+ border,
887
+ ),
888
+ );
889
+ lines.push(makeRow('', innerW, border));
890
+ for (const line of settingsList.render(innerW)) lines.push(makeRow(line, innerW, border));
891
+ lines.push(makeRow('', innerW, border));
892
+ const effective = resolveGoosedumpSettings(location);
893
+ lines.push(
894
+ makeRow(
895
+ dim(
896
+ `Effective: steps=${formatCompactSetting(effective.stepsBeforeCompact)} turns=${formatCompactSetting(effective.turnsBeforeCompact)}`,
897
+ ),
898
+ innerW,
899
+ border,
900
+ ),
901
+ );
902
+ lines.push(
903
+ makeRow(dim('tab switch ↑↓ select enter/space cycle esc close'), innerW, border),
904
+ );
905
+ lines.push(`${borderFg('╰')}${borderFg('─'.repeat(width - 2))}${borderFg('╯')}`);
906
+ return lines;
907
+ }
908
+
764
909
  return {
765
910
  render(width: number): string[] {
766
911
  const innerW = width - 4;
@@ -771,21 +916,17 @@ async function runSessionBrowser(
771
916
  const text = (s: string) => theme.fg('text', s);
772
917
  const muted = (s: string) => theme.fg('muted', s);
773
918
 
774
- const lines: string[] = [];
775
-
776
919
  if (mode === 'compact') {
777
- const title = accent(` Compact: ${truncateToWidth(compactTitle, 40)} `);
920
+ const lines: string[] = [];
921
+ const title = accent(` Compact: ${truncateToWidth(compactTitle, 48)} `);
778
922
  const topFill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(title))));
779
923
  lines.push(`${borderFg('╭─')}${title}${topFill}${borderFg('─╮')}`);
780
924
 
781
925
  const total = compactLines.length;
782
926
  const window = compactLines.slice(compactScroll, compactScroll + COMPACT_VIEWPORT);
783
- for (const row of window) {
784
- lines.push(makeRow(text(row), innerW, border));
785
- }
786
- for (let i = window.length; i < COMPACT_VIEWPORT; i++) {
927
+ for (const row of window) lines.push(makeRow(text(row), innerW, border));
928
+ for (let i = window.length; i < COMPACT_VIEWPORT; i++)
787
929
  lines.push(makeRow('', innerW, border));
788
- }
789
930
 
790
931
  const position =
791
932
  total > COMPACT_VIEWPORT
@@ -797,85 +938,77 @@ async function runSessionBrowser(
797
938
  return lines;
798
939
  }
799
940
 
800
- // Top border
801
- const title = accent(' Goosedump Sessions ');
941
+ if (tab === 'settings') return renderSettings(width, innerW, border);
942
+
943
+ const currentListings = locationListings();
944
+ const lines: string[] = [];
945
+ const title = accent(' Goosedump Dashboard ');
802
946
  const topFill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(title))));
803
947
  lines.push(`${borderFg('╭─')}${title}${topFill}${borderFg('─╮')}`);
804
-
805
- // Session count
806
- const countInfo = `${listings.length} session${listings.length === 1 ? '' : 's'}`;
807
- lines.push(makeRow(`${muted(countInfo)}`, innerW, border));
808
-
948
+ lines.push(renderTabs(innerW, border));
949
+ lines.push(
950
+ makeRow(muted(`Location: ${shortPath(location, innerW - 10)}`), innerW, border),
951
+ );
952
+ lines.push(
953
+ makeRow(
954
+ muted(
955
+ `${currentListings.length} session${currentListings.length === 1 ? '' : 's'} here`,
956
+ ),
957
+ innerW,
958
+ border,
959
+ ),
960
+ );
809
961
  lines.push(makeRow('', innerW, border));
810
962
 
811
- // Session list
812
- const total = displayList.length;
813
- const window = displayList.slice(listScroll, listScroll + LIST_VIEWPORT);
963
+ const window = currentListings.slice(listScroll, listScroll + LIST_VIEWPORT);
814
964
  for (let i = 0; i < window.length; i++) {
815
965
  const absolute = listScroll + i;
816
- const entry = window[i];
966
+ const listing = window[i];
817
967
  const isSelected = absolute === selectedIndex;
818
- const deletePending =
819
- pendingDeletionId !== null && entry.listing.id === pendingDeletionId;
820
- const cursor = deletePending ? dim('␡ ') : isSelected ? accent('▶') : ' ';
821
- const indent = ' '.repeat(entry.depth * 2);
822
- const toggle = entry.hasChildren
823
- ? expandedNodes.has(entry.listing.id)
824
- ? muted('−')
825
- : muted('+')
826
- : ' ';
968
+ const deletePending = pendingDeletionId !== null && listing.id === pendingDeletionId;
969
+ const cursor = deletePending ? dim('␡') : isSelected ? accent('▶') : ' ';
827
970
  const idText = deletePending
828
- ? dim(entry.listing.id)
971
+ ? dim(listing.id)
829
972
  : isSelected
830
- ? accent(entry.listing.id)
831
- : text(entry.listing.id);
832
-
833
- // Prefix: " ▶" (3) or "␡ " (3) + indent + toggle + " " + id (8) + " "
834
- const prefixLen = 3 + entry.depth * 2 + 1 + 1 + 8 + 1;
835
- const cwdMax = Math.max(8, innerW - prefixLen);
836
- const cwdTruncated = truncateCwd(entry.listing.cwd, cwdMax);
837
- const cwdText = deletePending
838
- ? muted(cwdTruncated)
839
- : isSelected
840
- ? accent(cwdTruncated)
841
- : muted(cwdTruncated);
842
-
843
- const line = ` ${cursor}${indent}${toggle} ${idText} ${cwdText}`;
844
- lines.push(makeRow(line, innerW, border));
973
+ ? accent(listing.id)
974
+ : text(listing.id);
975
+ const label = listing.label ? ` ${listing.label}` : '';
976
+ const count = muted(
977
+ ` (${listing.provider}, ${listing.path ? 'resumable' : 'no file'})`,
978
+ );
979
+ lines.push(makeRow(` ${cursor} ${idText}${muted(label)}${count}`, innerW, border));
845
980
  }
846
981
 
847
- if (total > LIST_VIEWPORT) {
848
- const position = `${listScroll + 1}-${Math.min(listScroll + LIST_VIEWPORT, total)} of ${total}`;
849
- lines.push(makeRow(dim(` (${position})`), innerW, border));
982
+ for (let i = window.length; i < LIST_VIEWPORT; i++)
983
+ lines.push(makeRow('', innerW, border));
984
+
985
+ if (currentListings.length > LIST_VIEWPORT) {
986
+ const position = `${listScroll + 1}-${Math.min(listScroll + LIST_VIEWPORT, currentListings.length)} of ${currentListings.length}`;
987
+ lines.push(makeRow(dim(`(${position})`), innerW, border));
850
988
  }
851
989
 
852
- // Deletion confirmation
853
990
  if (pendingDeletionId) {
854
991
  lines.push(makeRow('', innerW, border));
855
992
  lines.push(
856
993
  makeRow(
857
- dim(` Delete session ${pendingDeletionId}? enter=confirm esc=cancel`),
994
+ dim(`Delete session ${pendingDeletionId}? enter=confirm esc=cancel`),
858
995
  innerW,
859
996
  border,
860
997
  ),
861
998
  );
862
999
  }
863
1000
 
864
- // Footer
865
1001
  lines.push(makeRow('', innerW, border));
866
1002
  lines.push(
867
1003
  makeRow(
868
1004
  dim(
869
- '↑↓ navigate space/+/− fold enter compact shift+enter resume ctrl+d delete esc close',
1005
+ 'tab settings ↑↓ navigate enter compact shift+enter resume ctrl+d delete esc close',
870
1006
  ),
871
1007
  innerW,
872
1008
  border,
873
1009
  ),
874
1010
  );
875
-
876
- // Bottom border
877
1011
  lines.push(`${borderFg('╰')}${borderFg('─'.repeat(width - 2))}${borderFg('╯')}`);
878
-
879
1012
  return lines;
880
1013
  },
881
1014
 
@@ -910,11 +1043,29 @@ async function runSessionBrowser(
910
1043
  return;
911
1044
  }
912
1045
 
1046
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift(Key.tab))) {
1047
+ tab = tab === 'sessions' ? 'settings' : 'sessions';
1048
+ if (tab === 'settings') rebuildSettingsList();
1049
+ tui.requestRender();
1050
+ return;
1051
+ }
1052
+
1053
+ if (tab === 'settings') {
1054
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
1055
+ done(undefined);
1056
+ return;
1057
+ }
1058
+ settingsList.handleInput(data);
1059
+ tui.requestRender();
1060
+ return;
1061
+ }
1062
+
913
1063
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
914
1064
  done(undefined);
915
1065
  return;
916
1066
  }
917
1067
 
1068
+ const currentListings = locationListings();
918
1069
  if (matchesKey(data, Key.up)) {
919
1070
  selectedIndex = Math.max(0, selectedIndex - 1);
920
1071
  if (selectedIndex < listScroll) listScroll = selectedIndex;
@@ -923,7 +1074,7 @@ async function runSessionBrowser(
923
1074
  }
924
1075
 
925
1076
  if (matchesKey(data, Key.down)) {
926
- selectedIndex = Math.min(displayList.length - 1, selectedIndex + 1);
1077
+ selectedIndex = Math.min(Math.max(0, currentListings.length - 1), selectedIndex + 1);
927
1078
  if (selectedIndex >= listScroll + LIST_VIEWPORT) {
928
1079
  listScroll = selectedIndex - LIST_VIEWPORT + 1;
929
1080
  }
@@ -931,7 +1082,6 @@ async function runSessionBrowser(
931
1082
  return;
932
1083
  }
933
1084
 
934
- // Deletion confirmation: enter confirms, esc cancels
935
1085
  if (pendingDeletionId) {
936
1086
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
937
1087
  pendingDeletionId = null;
@@ -945,47 +1095,21 @@ async function runSessionBrowser(
945
1095
  return;
946
1096
  }
947
1097
 
948
- // Ctrl+D marks session for deletion
949
1098
  if (matchesKey(data, Key.ctrl('d'))) {
950
- const listing = displayList[selectedIndex]?.listing;
1099
+ const listing = currentListings[selectedIndex];
951
1100
  if (listing) pendingDeletionId = listing.id;
952
1101
  tui.requestRender();
953
1102
  return;
954
1103
  }
955
1104
 
956
- // Expand/collapse: space, +, or - key
957
- const entry = displayList[selectedIndex];
958
- if (entry && entry.hasChildren && (data === ' ' || data === '+' || data === '-')) {
959
- const id = entry.listing.id;
960
- if (data === '+') {
961
- expandedNodes.add(id);
962
- } else if (data === '-') {
963
- expandedNodes.delete(id);
964
- } else {
965
- // space = toggle
966
- if (expandedNodes.has(id)) {
967
- expandedNodes.delete(id);
968
- } else {
969
- expandedNodes.add(id);
970
- }
971
- }
972
- rebuildDisplayList();
973
- // Keep selection on the same entry
974
- const newIndex = displayList.findIndex((d) => d.listing.id === id);
975
- if (newIndex >= 0) selectedIndex = newIndex;
976
- if (selectedIndex < listScroll) listScroll = selectedIndex;
977
- tui.requestRender();
978
- return;
979
- }
980
-
981
1105
  if (matchesKey(data, Key.shift(Key.enter))) {
982
- resumePath = displayList[selectedIndex]?.listing.path;
1106
+ resumePath = currentListings[selectedIndex]?.path;
983
1107
  done(undefined);
984
1108
  return;
985
1109
  }
986
1110
 
987
1111
  if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) {
988
- const listing = displayList[selectedIndex]?.listing;
1112
+ const listing = currentListings[selectedIndex];
989
1113
  if (!listing) return;
990
1114
  compactTitle = listing.id;
991
1115
  compactScroll = 0;
@@ -1000,18 +1124,11 @@ async function runSessionBrowser(
1000
1124
  }
1001
1125
  mode = 'compact';
1002
1126
  tui.requestRender();
1003
- return;
1004
1127
  }
1005
1128
  },
1006
1129
 
1007
1130
  invalidate(): void {},
1008
1131
  };
1009
-
1010
- function makeRow(content: string, innerW: number, border: string): string {
1011
- const line = truncateToWidth(content, innerW);
1012
- const pad = Math.max(0, innerW - visibleWidth(line));
1013
- return `${border} ${line}${' '.repeat(pad)} ${border}`;
1014
- }
1015
1132
  },
1016
1133
  {
1017
1134
  overlay: true,
@@ -1033,6 +1150,64 @@ export function createGoosedumpIntegration(
1033
1150
  const shouldOverrideDefaultCompaction = options.overrideDefaultCompaction ?? true;
1034
1151
 
1035
1152
  let goosedumpAvailable = false;
1153
+ let compactSettings = DEFAULT_GOOSEDUMP_SETTINGS;
1154
+ let settingsSignature = '';
1155
+ let stepsRemaining = 0;
1156
+ let turnsRemaining = 0;
1157
+ let counterTriggered = false;
1158
+ let compactionQueued = false;
1159
+
1160
+ function compactSettingsSignature(settings: GoosedumpSettings): string {
1161
+ return `${settings.stepsBeforeCompact}:${settings.turnsBeforeCompact}`;
1162
+ }
1163
+
1164
+ function resetCompactCounters(cwd: string): void {
1165
+ compactSettings = resolveGoosedumpSettings(cwd);
1166
+ settingsSignature = compactSettingsSignature(compactSettings);
1167
+ stepsRemaining = compactSettings.stepsBeforeCompact;
1168
+ turnsRemaining = compactSettings.turnsBeforeCompact;
1169
+ counterTriggered = false;
1170
+ }
1171
+
1172
+ function reloadCompactSettings(cwd: string): void {
1173
+ const next = resolveGoosedumpSettings(cwd);
1174
+ const nextSignature = compactSettingsSignature(next);
1175
+ if (nextSignature !== settingsSignature) resetCompactCounters(cwd);
1176
+ }
1177
+
1178
+ function noteCompletedStep(ctx: ExtensionContext): void {
1179
+ if (!goosedumpAvailable || !shouldOverrideDefaultCompaction) return;
1180
+ reloadCompactSettings(ctx.cwd);
1181
+ if (compactSettings.stepsBeforeCompact === 0) return;
1182
+
1183
+ stepsRemaining -= 1;
1184
+ if (stepsRemaining <= 0) counterTriggered = true;
1185
+ }
1186
+
1187
+ function noteCompletedTurn(ctx: ExtensionContext): void {
1188
+ if (!goosedumpAvailable || !shouldOverrideDefaultCompaction) return;
1189
+ reloadCompactSettings(ctx.cwd);
1190
+ if (compactSettings.turnsBeforeCompact > 0) {
1191
+ turnsRemaining -= 1;
1192
+ if (turnsRemaining <= 0) counterTriggered = true;
1193
+ }
1194
+
1195
+ if (!counterTriggered || compactionQueued) return;
1196
+
1197
+ resetCompactCounters(ctx.cwd);
1198
+ compactionQueued = true;
1199
+ ctx.compact({
1200
+ onComplete: () => {
1201
+ compactionQueued = false;
1202
+ resetCompactCounters(ctx.cwd);
1203
+ },
1204
+ onError: (error) => {
1205
+ compactionQueued = false;
1206
+ const message = error instanceof Error ? error.message : String(error);
1207
+ if (ctx.hasUI) ctx.ui.notify(`goosedump compact failed: ${message}`, 'error');
1208
+ },
1209
+ });
1210
+ }
1036
1211
 
1037
1212
  function checkGoosedump(ctx?: ExtensionContext): boolean {
1038
1213
  const version = goosedumpVersion();
@@ -1324,6 +1499,7 @@ export function createGoosedumpIntegration(
1324
1499
 
1325
1500
  pi.on('session_start', async (_event, ctx) => {
1326
1501
  goosedumpAvailable = checkGoosedump(ctx);
1502
+ resetCompactCounters(ctx.cwd);
1327
1503
 
1328
1504
  if (goosedumpAvailable) {
1329
1505
  const theme = ctx.ui.theme;
@@ -1333,6 +1509,14 @@ export function createGoosedumpIntegration(
1333
1509
  }
1334
1510
  });
1335
1511
 
1512
+ pi.on('tool_execution_end', async (_event, ctx) => {
1513
+ noteCompletedStep(ctx);
1514
+ });
1515
+
1516
+ pi.on('turn_end', async (_event, ctx) => {
1517
+ noteCompletedTurn(ctx);
1518
+ });
1519
+
1336
1520
  pi.on('session_before_compact', async (event, ctx) => {
1337
1521
  if (!shouldOverrideDefaultCompaction || !goosedumpAvailable) return;
1338
1522
  if (event.customInstructions?.trim()) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goosedump",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Coding agent context data browser plugin for pi",
5
5
  "keywords": [
6
6
  "goosedump",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@earendil-works/pi-tui": "^0.78.0",
32
- "@jarkkojs/goosedump": "^0.7.4",
32
+ "@jarkkojs/goosedump": "^0.8.1",
33
33
  "@sinclair/typebox": "^0.34.49"
34
34
  },
35
35
  "devDependencies": {