pi-goosedump 0.7.3 → 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 -214
  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,26 +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
- // Refresh listings — goosedump may have also deleted subagent sessions
748
- listings.splice(0, listings.length, ...goosedumpList());
749
- rebuildTree();
750
- rebuildDisplayList();
751
- if (selectedIndex >= displayList.length) {
752
- selectedIndex = Math.max(0, displayList.length - 1);
753
- }
850
+ refreshListings();
851
+ resetSelection();
754
852
  pendingDeletionId = null;
755
853
  } catch (err) {
756
854
  const msg = err instanceof Error ? err.message : String(err);
@@ -760,6 +858,54 @@ async function runSessionBrowser(
760
858
  tui.requestRender();
761
859
  }
762
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
+
763
909
  return {
764
910
  render(width: number): string[] {
765
911
  const innerW = width - 4;
@@ -770,21 +916,17 @@ async function runSessionBrowser(
770
916
  const text = (s: string) => theme.fg('text', s);
771
917
  const muted = (s: string) => theme.fg('muted', s);
772
918
 
773
- const lines: string[] = [];
774
-
775
919
  if (mode === 'compact') {
776
- const title = accent(` Compact: ${truncateToWidth(compactTitle, 40)} `);
920
+ const lines: string[] = [];
921
+ const title = accent(` Compact: ${truncateToWidth(compactTitle, 48)} `);
777
922
  const topFill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(title))));
778
923
  lines.push(`${borderFg('╭─')}${title}${topFill}${borderFg('─╮')}`);
779
924
 
780
925
  const total = compactLines.length;
781
926
  const window = compactLines.slice(compactScroll, compactScroll + COMPACT_VIEWPORT);
782
- for (const row of window) {
783
- lines.push(makeRow(text(row), innerW, border));
784
- }
785
- 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++)
786
929
  lines.push(makeRow('', innerW, border));
787
- }
788
930
 
789
931
  const position =
790
932
  total > COMPACT_VIEWPORT
@@ -796,85 +938,77 @@ async function runSessionBrowser(
796
938
  return lines;
797
939
  }
798
940
 
799
- // Top border
800
- 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 ');
801
946
  const topFill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(title))));
802
947
  lines.push(`${borderFg('╭─')}${title}${topFill}${borderFg('─╮')}`);
803
-
804
- // Session count
805
- const countInfo = `${listings.length} session${listings.length === 1 ? '' : 's'}`;
806
- lines.push(makeRow(`${muted(countInfo)}`, innerW, border));
807
-
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
+ );
808
961
  lines.push(makeRow('', innerW, border));
809
962
 
810
- // Session list
811
- const total = displayList.length;
812
- const window = displayList.slice(listScroll, listScroll + LIST_VIEWPORT);
963
+ const window = currentListings.slice(listScroll, listScroll + LIST_VIEWPORT);
813
964
  for (let i = 0; i < window.length; i++) {
814
965
  const absolute = listScroll + i;
815
- const entry = window[i];
966
+ const listing = window[i];
816
967
  const isSelected = absolute === selectedIndex;
817
- const deletePending =
818
- pendingDeletionId !== null && entry.listing.id === pendingDeletionId;
819
- const cursor = deletePending ? dim('␡ ') : isSelected ? accent('▶') : ' ';
820
- const indent = ' '.repeat(entry.depth * 2);
821
- const toggle = entry.hasChildren
822
- ? expandedNodes.has(entry.listing.id)
823
- ? muted('−')
824
- : muted('+')
825
- : ' ';
968
+ const deletePending = pendingDeletionId !== null && listing.id === pendingDeletionId;
969
+ const cursor = deletePending ? dim('␡') : isSelected ? accent('▶') : ' ';
826
970
  const idText = deletePending
827
- ? dim(entry.listing.id)
971
+ ? dim(listing.id)
828
972
  : isSelected
829
- ? accent(entry.listing.id)
830
- : text(entry.listing.id);
831
-
832
- // Prefix: " ▶" (3) or "␡ " (3) + indent + toggle + " " + id (8) + " "
833
- const prefixLen = 3 + entry.depth * 2 + 1 + 1 + 8 + 1;
834
- const cwdMax = Math.max(8, innerW - prefixLen);
835
- const cwdTruncated = truncateCwd(entry.listing.cwd, cwdMax);
836
- const cwdText = deletePending
837
- ? muted(cwdTruncated)
838
- : isSelected
839
- ? accent(cwdTruncated)
840
- : muted(cwdTruncated);
841
-
842
- const line = ` ${cursor}${indent}${toggle} ${idText} ${cwdText}`;
843
- 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));
844
980
  }
845
981
 
846
- if (total > LIST_VIEWPORT) {
847
- const position = `${listScroll + 1}-${Math.min(listScroll + LIST_VIEWPORT, total)} of ${total}`;
848
- 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));
849
988
  }
850
989
 
851
- // Deletion confirmation
852
990
  if (pendingDeletionId) {
853
991
  lines.push(makeRow('', innerW, border));
854
992
  lines.push(
855
993
  makeRow(
856
- dim(` Delete session ${pendingDeletionId}? enter=confirm esc=cancel`),
994
+ dim(`Delete session ${pendingDeletionId}? enter=confirm esc=cancel`),
857
995
  innerW,
858
996
  border,
859
997
  ),
860
998
  );
861
999
  }
862
1000
 
863
- // Footer
864
1001
  lines.push(makeRow('', innerW, border));
865
1002
  lines.push(
866
1003
  makeRow(
867
1004
  dim(
868
- '↑↓ 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',
869
1006
  ),
870
1007
  innerW,
871
1008
  border,
872
1009
  ),
873
1010
  );
874
-
875
- // Bottom border
876
1011
  lines.push(`${borderFg('╰')}${borderFg('─'.repeat(width - 2))}${borderFg('╯')}`);
877
-
878
1012
  return lines;
879
1013
  },
880
1014
 
@@ -909,11 +1043,29 @@ async function runSessionBrowser(
909
1043
  return;
910
1044
  }
911
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
+
912
1063
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
913
1064
  done(undefined);
914
1065
  return;
915
1066
  }
916
1067
 
1068
+ const currentListings = locationListings();
917
1069
  if (matchesKey(data, Key.up)) {
918
1070
  selectedIndex = Math.max(0, selectedIndex - 1);
919
1071
  if (selectedIndex < listScroll) listScroll = selectedIndex;
@@ -922,7 +1074,7 @@ async function runSessionBrowser(
922
1074
  }
923
1075
 
924
1076
  if (matchesKey(data, Key.down)) {
925
- selectedIndex = Math.min(displayList.length - 1, selectedIndex + 1);
1077
+ selectedIndex = Math.min(Math.max(0, currentListings.length - 1), selectedIndex + 1);
926
1078
  if (selectedIndex >= listScroll + LIST_VIEWPORT) {
927
1079
  listScroll = selectedIndex - LIST_VIEWPORT + 1;
928
1080
  }
@@ -930,7 +1082,6 @@ async function runSessionBrowser(
930
1082
  return;
931
1083
  }
932
1084
 
933
- // Deletion confirmation: enter confirms, esc cancels
934
1085
  if (pendingDeletionId) {
935
1086
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
936
1087
  pendingDeletionId = null;
@@ -944,47 +1095,21 @@ async function runSessionBrowser(
944
1095
  return;
945
1096
  }
946
1097
 
947
- // Ctrl+D marks session for deletion
948
1098
  if (matchesKey(data, Key.ctrl('d'))) {
949
- const listing = displayList[selectedIndex]?.listing;
1099
+ const listing = currentListings[selectedIndex];
950
1100
  if (listing) pendingDeletionId = listing.id;
951
1101
  tui.requestRender();
952
1102
  return;
953
1103
  }
954
1104
 
955
- // Expand/collapse: space, +, or - key
956
- const entry = displayList[selectedIndex];
957
- if (entry && entry.hasChildren && (data === ' ' || data === '+' || data === '-')) {
958
- const id = entry.listing.id;
959
- if (data === '+') {
960
- expandedNodes.add(id);
961
- } else if (data === '-') {
962
- expandedNodes.delete(id);
963
- } else {
964
- // space = toggle
965
- if (expandedNodes.has(id)) {
966
- expandedNodes.delete(id);
967
- } else {
968
- expandedNodes.add(id);
969
- }
970
- }
971
- rebuildDisplayList();
972
- // Keep selection on the same entry
973
- const newIndex = displayList.findIndex((d) => d.listing.id === id);
974
- if (newIndex >= 0) selectedIndex = newIndex;
975
- if (selectedIndex < listScroll) listScroll = selectedIndex;
976
- tui.requestRender();
977
- return;
978
- }
979
-
980
1105
  if (matchesKey(data, Key.shift(Key.enter))) {
981
- resumePath = displayList[selectedIndex]?.listing.path;
1106
+ resumePath = currentListings[selectedIndex]?.path;
982
1107
  done(undefined);
983
1108
  return;
984
1109
  }
985
1110
 
986
1111
  if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) {
987
- const listing = displayList[selectedIndex]?.listing;
1112
+ const listing = currentListings[selectedIndex];
988
1113
  if (!listing) return;
989
1114
  compactTitle = listing.id;
990
1115
  compactScroll = 0;
@@ -999,18 +1124,11 @@ async function runSessionBrowser(
999
1124
  }
1000
1125
  mode = 'compact';
1001
1126
  tui.requestRender();
1002
- return;
1003
1127
  }
1004
1128
  },
1005
1129
 
1006
1130
  invalidate(): void {},
1007
1131
  };
1008
-
1009
- function makeRow(content: string, innerW: number, border: string): string {
1010
- const line = truncateToWidth(content, innerW);
1011
- const pad = Math.max(0, innerW - visibleWidth(line));
1012
- return `${border} ${line}${' '.repeat(pad)} ${border}`;
1013
- }
1014
1132
  },
1015
1133
  {
1016
1134
  overlay: true,
@@ -1032,6 +1150,64 @@ export function createGoosedumpIntegration(
1032
1150
  const shouldOverrideDefaultCompaction = options.overrideDefaultCompaction ?? true;
1033
1151
 
1034
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
+ }
1035
1211
 
1036
1212
  function checkGoosedump(ctx?: ExtensionContext): boolean {
1037
1213
  const version = goosedumpVersion();
@@ -1323,6 +1499,7 @@ export function createGoosedumpIntegration(
1323
1499
 
1324
1500
  pi.on('session_start', async (_event, ctx) => {
1325
1501
  goosedumpAvailable = checkGoosedump(ctx);
1502
+ resetCompactCounters(ctx.cwd);
1326
1503
 
1327
1504
  if (goosedumpAvailable) {
1328
1505
  const theme = ctx.ui.theme;
@@ -1332,6 +1509,14 @@ export function createGoosedumpIntegration(
1332
1509
  }
1333
1510
  });
1334
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
+
1335
1520
  pi.on('session_before_compact', async (event, ctx) => {
1336
1521
  if (!shouldOverrideDefaultCompaction || !goosedumpAvailable) return;
1337
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.3",
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": {