pi-goosedump 0.7.3 → 0.8.1

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 +16 -1
  2. package/index.ts +435 -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,17 @@ 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
+ An optional goosedump counter can also trigger compaction. `0` disables the counter;
90
+ when it fires, it resets to its configured value.
91
+
92
+ ```json
93
+ {
94
+ "goosedump": {
95
+ "turnsBeforeCompact": 0
96
+ }
97
+ }
98
+ ```
99
+
85
100
  ## License
86
101
 
87
102
  `pi-goosedump` is licensed under `MIT`. See [LICENSE](LICENSE) for more
package/index.ts CHANGED
@@ -10,16 +10,22 @@ 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
+ type Component,
23
+ Input,
22
24
  Key,
25
+ SettingsList,
26
+ type SettingItem,
27
+ decodeKittyPrintable,
28
+ getKeybindings,
23
29
  matchesKey,
24
30
  truncateToWidth,
25
31
  visibleWidth,
@@ -29,25 +35,24 @@ import { Type } from '@sinclair/typebox';
29
35
 
30
36
  const require = createRequire(import.meta.url);
31
37
 
32
- const GOOSEDUMP_VERSION = [0, 7, 4] as const;
38
+ const GOOSEDUMP_VERSION = [0, 8, 2] as const;
33
39
 
34
- // One element of goosedump 0.7's `list` JSON output.
40
+ // One element of goosedump 0.8's `list` JSON output.
35
41
  interface ListEntryJson {
36
42
  id: string;
37
43
  parent: string | null;
38
44
  provider: string;
39
- native_id: string;
40
45
  cwd: string;
41
46
  count: number;
42
47
  label: string | null;
43
48
  from: string | null;
44
49
  until: string | null;
50
+ native_id?: string;
45
51
  }
46
52
 
47
53
  interface GoosedumpListing {
48
54
  id: string;
49
55
  provider: string;
50
- nativeId: string;
51
56
  cwd: string;
52
57
  parent: string | null;
53
58
  label: string | null;
@@ -66,24 +71,26 @@ interface GoosedumpSearchResult {
66
71
  page: number;
67
72
  totalPages: number;
68
73
  }
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;
74
+ interface ShowPartJson {
75
+ type: 'text' | 'reasoning' | 'toolCall' | 'toolResult' | 'image' | 'bash' | 'passthrough';
79
76
  text?: string;
80
- thinking?: string[];
81
- toolCalls?: ToolCallJson[];
77
+ name?: string;
78
+ arguments?: unknown;
82
79
  toolName?: string;
83
80
  content?: string;
84
81
  isError?: boolean;
82
+ mimeType?: string;
83
+ ocrText?: string;
85
84
  command?: string;
86
85
  output?: string;
86
+ kind?: string;
87
+ raw?: unknown;
88
+ }
89
+
90
+ interface ShowMessageJson {
91
+ entryId: string;
92
+ role: string;
93
+ parts: ShowPartJson[];
87
94
  }
88
95
 
89
96
  interface ShowJson {
@@ -115,6 +122,87 @@ interface CompactJson {
115
122
  brief: string;
116
123
  }
117
124
 
125
+ interface GoosedumpSettings {
126
+ turnsBeforeCompact: number;
127
+ }
128
+
129
+ type SettingsScope = 'global' | 'project';
130
+ type GoosedumpSettingKey = keyof GoosedumpSettings;
131
+
132
+ const DEFAULT_GOOSEDUMP_SETTINGS: GoosedumpSettings = {
133
+ turnsBeforeCompact: 0,
134
+ };
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
+ const value = raw.turnsBeforeCompact;
169
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) {
170
+ settings.turnsBeforeCompact = value;
171
+ }
172
+ return settings;
173
+ }
174
+
175
+ function resolveGoosedumpSettings(cwd: string): GoosedumpSettings {
176
+ return {
177
+ ...DEFAULT_GOOSEDUMP_SETTINGS,
178
+ ...readGoosedumpSettingsFrom(globalSettingsPath()),
179
+ ...readGoosedumpSettingsFrom(projectSettingsPath(cwd)),
180
+ };
181
+ }
182
+
183
+ function readScopedGoosedumpSettings(cwd: string, scope: SettingsScope): GoosedumpSettings {
184
+ const path = scope === 'global' ? globalSettingsPath() : projectSettingsPath(cwd);
185
+ return { ...DEFAULT_GOOSEDUMP_SETTINGS, ...readGoosedumpSettingsFrom(path) };
186
+ }
187
+
188
+ function writeScopedGoosedumpSetting(
189
+ cwd: string,
190
+ scope: SettingsScope,
191
+ key: GoosedumpSettingKey,
192
+ value: number,
193
+ ): void {
194
+ const path = scope === 'global' ? globalSettingsPath() : projectSettingsPath(cwd);
195
+ const root = readJsonObject(path);
196
+ const goosedump = isRecord(root.goosedump) ? root.goosedump : {};
197
+ root.goosedump = { ...goosedump, [key]: value };
198
+ mkdirSync(dirname(path), { recursive: true });
199
+ writeFileSync(path, `${JSON.stringify(root, null, 2)}\n`);
200
+ }
201
+
202
+ function formatCompactSetting(value: number): string {
203
+ return value === 0 ? 'disabled' : String(value);
204
+ }
205
+
118
206
  function resolveGoosedumpBinary(): string {
119
207
  try {
120
208
  return require.resolve('@jarkkojs/goosedump/bin/goosedump.js');
@@ -181,51 +269,43 @@ function summarizeToolArgs(args: unknown): string {
181
269
  return clip(JSON.stringify(args), 160);
182
270
  }
183
271
 
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');
272
+ function showPartContent(part: ShowPartJson): string {
273
+ switch (part.type) {
274
+ case 'text':
275
+ case 'reasoning':
276
+ return part.text ?? '';
277
+ case 'toolCall': {
278
+ const summary = summarizeToolArgs(part.arguments);
279
+ return summary ? `${part.name ?? 'tool'}(${summary})` : (part.name ?? 'tool');
280
+ }
281
+ case 'toolResult': {
282
+ const name = part.toolName ?? 'tool';
283
+ const header = `${name}${part.isError ? ' ⚠' : ''}`;
284
+ return part.content ? `${header}\n${part.content}` : header;
285
+ }
286
+ case 'image': {
287
+ const header = `[image ${part.mimeType ?? 'unknown'}]`;
288
+ return part.ocrText ? `${header}\n${part.ocrText}` : header;
214
289
  }
215
- case 'toolResult':
216
- return m.content ?? '';
217
290
  case 'bash': {
218
- const cmd = m.command ? `$ ${m.command}` : '';
219
- if (cmd && m.output) return `${cmd}\n${m.output}`;
220
- return cmd || (m.output ?? '');
291
+ const cmd = part.command ? `$ ${part.command}` : '';
292
+ if (cmd && part.output) return `${cmd}\n${part.output}`;
293
+ return cmd || (part.output ?? '');
294
+ }
295
+ case 'passthrough': {
296
+ const kind = part.kind ? `passthrough:${part.kind}` : 'passthrough';
297
+ const raw = part.raw === undefined ? '' : JSON.stringify(part.raw);
298
+ return raw ? `[${kind}] ${raw}` : `[${kind}]`;
221
299
  }
222
- default:
223
- return m.text ?? '';
224
300
  }
225
301
  }
226
302
 
303
+ function showMessageContent(m: ShowMessageJson): string {
304
+ return m.parts.map(showPartContent).filter(Boolean).join('\n');
305
+ }
306
+
227
307
  function showMessage(m: ShowMessageJson): GoosedumpMessage {
228
- return { id: m.entryId, role: showMessageRole(m), content: showMessageContent(m) };
308
+ return { id: m.entryId, role: m.role, content: showMessageContent(m) };
229
309
  }
230
310
 
231
311
  function hitMessage(h: HitJson): GoosedumpMessage {
@@ -269,16 +349,18 @@ function goosedumpList(provider = 'pi'): GoosedumpListing[] {
269
349
  const entries = JSON.parse(runGoosedump(['list'])) as ListEntryJson[];
270
350
  return entries
271
351
  .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
- }));
352
+ .map((entry) => {
353
+ const nativeId = entry.native_id ?? entry.id;
354
+ return {
355
+ id: entry.id,
356
+ provider: entry.provider,
357
+ cwd: entry.cwd,
358
+ parent: entry.parent,
359
+ label: entry.label ?? null,
360
+ // Only pi sessions map back to a local file we can resume.
361
+ path: provider === 'pi' ? piSessionFilePath(nativeId) : null,
362
+ };
363
+ });
282
364
  }
283
365
 
284
366
  function piSessionsDir(): string {
@@ -475,7 +557,7 @@ function currentSessionContextId(ctx: ExtensionContext): string | null {
475
557
  const sessionFile = ctx.sessionManager.getSessionFile();
476
558
  if (!sessionFile) return null;
477
559
  const nativeId = sessionFileNativeId(sessionFile);
478
- const listing = goosedumpList('pi').find((entry) => entry.nativeId === nativeId);
560
+ const listing = goosedumpList('pi').find((entry) => entry.id === nativeId);
479
561
  return listing ? listing.id : null;
480
562
  }
481
563
 
@@ -663,67 +745,119 @@ function buildGoosedumpCompaction(
663
745
  };
664
746
  }
665
747
 
748
+ function isControlChar(char: string): boolean {
749
+ const code = char.charCodeAt(0);
750
+ return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
751
+ }
752
+
753
+ class NumberInputSubmenu implements Component {
754
+ private readonly input = new Input();
755
+ private readonly hint: (text: string) => string;
756
+ private readonly onSubmitValue: (value: string) => void;
757
+ private readonly onCancelEdit: () => void;
758
+ private readonly title: string;
759
+
760
+ constructor(
761
+ title: string,
762
+ initialValue: string,
763
+ onSubmit: (value: string) => void,
764
+ onCancel: () => void,
765
+ ) {
766
+ this.title = title;
767
+ this.onSubmitValue = onSubmit;
768
+ this.onCancelEdit = onCancel;
769
+ this.input.setValue(initialValue);
770
+ this.hint = getSettingsListTheme().hint;
771
+ }
772
+
773
+ handleInput(data: string): void {
774
+ const kb = getKeybindings();
775
+ if (kb.matches(data, 'tui.select.confirm') || data === '\n') {
776
+ const digits = this.input.getValue().replace(/\D/g, '');
777
+ const parsed = Number(digits);
778
+ this.onSubmitValue(digits !== '' && Number.isSafeInteger(parsed) ? String(parsed) : '0');
779
+ return;
780
+ }
781
+ if (kb.matches(data, 'tui.select.cancel')) {
782
+ this.onCancelEdit();
783
+ return;
784
+ }
785
+ const kittyChar = decodeKittyPrintable(data);
786
+ const printable = kittyChar ?? (data.length === 1 && !isControlChar(data) ? data : undefined);
787
+ if (printable !== undefined && !/^[0-9]$/.test(printable)) return;
788
+ this.input.handleInput(data);
789
+ }
790
+
791
+ invalidate(): void {
792
+ this.input.invalidate();
793
+ }
794
+
795
+ render(width: number): string[] {
796
+ return [
797
+ this.hint(` ${this.title}`),
798
+ '',
799
+ ...this.input.render(width),
800
+ '',
801
+ this.hint(' enter submit · esc cancel · digits only'),
802
+ ];
803
+ }
804
+ }
805
+
666
806
  async function runSessionBrowser(
667
807
  ctx: ExtensionContext,
668
808
  listings: GoosedumpListing[],
669
809
  ): Promise<string | null | undefined> {
670
810
  let resumePath: string | null | undefined;
671
811
 
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
- }
812
+ const OVERLAY_WIDTH = 84;
813
+ const LIST_VIEWPORT = 18;
814
+ const COMPACT_VIEWPORT = 18;
815
+ const location = ctx.cwd;
816
+
817
+ function locationListings(): GoosedumpListing[] {
818
+ return listings.filter((listing) => listing.cwd === location);
684
819
  }
685
820
 
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
- }
821
+ function refreshListings(): void {
822
+ listings.splice(0, listings.length, ...goosedumpList());
697
823
  }
698
824
 
699
- function truncateCwd(cwd: string, maxLen: number): string {
700
- if (cwd.length <= maxLen) return cwd;
825
+ function shortPath(path: string, maxLen: number): string {
826
+ if (path.length <= maxLen) return path;
701
827
  const head = Math.floor(maxLen / 2) + 2;
702
828
  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);
829
+ if (tail < 4) return `${path.slice(0, maxLen - 3)}...`;
830
+ return `${path.slice(0, head)}...${path.slice(path.length - tail)}`;
705
831
  }
706
832
 
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;
833
+ function settingLabel(scope: SettingsScope, key: GoosedumpSettingKey): string {
834
+ const scopeLabel = scope === 'global' ? 'Global' : 'Project';
835
+ return `${scopeLabel} ${key}`;
836
+ }
837
+
838
+ function buildSettingItems(): SettingItem[] {
839
+ const global = readScopedGoosedumpSettings(location, 'global');
840
+ const project = readScopedGoosedumpSettings(location, 'project');
841
+ const settingsByScope = { global, project } satisfies Record<SettingsScope, GoosedumpSettings>;
842
+
843
+ return (['global', 'project'] as const).map((scope) => ({
844
+ id: `${scope}:turnsBeforeCompact`,
845
+ label: settingLabel(scope, 'turnsBeforeCompact'),
846
+ description: 'LLM turns before goosedump starts compaction; 0 disables this trigger.',
847
+ currentValue: String(settingsByScope[scope].turnsBeforeCompact),
848
+ submenu: (currentValue, done) =>
849
+ new NumberInputSubmenu(
850
+ settingLabel(scope, 'turnsBeforeCompact'),
851
+ currentValue,
852
+ (value) => done(value),
853
+ () => done(),
854
+ ),
855
+ }));
721
856
  }
722
857
 
723
858
  await ctx.ui.custom<void>(
724
859
  (tui, theme, _kb, done) => {
725
- let expandedNodes = new Set<string>();
726
- let displayList = flattenTree(roots, expandedNodes, 0);
860
+ let tab: 'sessions' | 'settings' = 'sessions';
727
861
  let selectedIndex = 0;
728
862
  let listScroll = 0;
729
863
  let mode: 'list' | 'compact' = 'list';
@@ -731,26 +865,44 @@ async function runSessionBrowser(
731
865
  let compactScroll = 0;
732
866
  let compactTitle = '';
733
867
  let pendingDeletionId: string | null = null;
868
+ let settingsList: SettingsList;
734
869
 
735
- const COMPACT_VIEWPORT = 20;
736
- const LIST_VIEWPORT = 20;
737
870
  const wrapWidth = OVERLAY_WIDTH - 4;
738
871
 
739
- function rebuildDisplayList(): void {
740
- displayList = flattenTree(roots, expandedNodes, 0);
872
+ function resetSelection(): void {
873
+ const count = locationListings().length;
874
+ selectedIndex = Math.min(selectedIndex, Math.max(0, count - 1));
875
+ listScroll = Math.min(listScroll, Math.max(0, count - LIST_VIEWPORT));
876
+ }
877
+
878
+ function rebuildSettingsList(): void {
879
+ settingsList = new SettingsList(
880
+ buildSettingItems(),
881
+ 10,
882
+ getSettingsListTheme(),
883
+ (id, newValue) => {
884
+ const [scope, key] = id.split(':') as [SettingsScope, GoosedumpSettingKey];
885
+ const parsed = Number(newValue);
886
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return;
887
+ writeScopedGoosedumpSetting(location, scope, key, parsed);
888
+ settingsList.updateValue(id, newValue);
889
+ ctx.ui.notify(`${settingLabel(scope, key)} = ${formatCompactSetting(parsed)}`, 'info');
890
+ tui.requestRender();
891
+ },
892
+ () => {
893
+ done(undefined);
894
+ },
895
+ );
741
896
  }
742
897
 
898
+ rebuildSettingsList();
899
+
743
900
  function executeDeletion(): void {
744
901
  try {
745
902
  const id = pendingDeletionId!;
746
903
  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
- }
904
+ refreshListings();
905
+ resetSelection();
754
906
  pendingDeletionId = null;
755
907
  } catch (err) {
756
908
  const msg = err instanceof Error ? err.message : String(err);
@@ -760,6 +912,52 @@ async function runSessionBrowser(
760
912
  tui.requestRender();
761
913
  }
762
914
 
915
+ function makeRow(content: string, innerW: number, border: string): string {
916
+ const line = truncateToWidth(content, innerW);
917
+ const pad = Math.max(0, innerW - visibleWidth(line));
918
+ return `${border} ${line}${' '.repeat(pad)} ${border}`;
919
+ }
920
+
921
+ function renderTabs(innerW: number, border: string): string {
922
+ const sessions = tab === 'sessions' ? theme.fg('accent', '[Sessions]') : '[Sessions]';
923
+ const settings = tab === 'settings' ? theme.fg('accent', '[Settings]') : '[Settings]';
924
+ return makeRow(`${sessions} ${settings}`, innerW, border);
925
+ }
926
+
927
+ function renderSettings(width: number, innerW: number, border: string): string[] {
928
+ const borderFg = (s: string) => theme.fg('border', s);
929
+ const accent = (s: string) => theme.fg('accent', s);
930
+ const dim = (s: string) => theme.fg('dim', s);
931
+ const lines: string[] = [];
932
+ const title = accent(' Goosedump Dashboard ');
933
+ const topFill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(title))));
934
+ lines.push(`${borderFg('╭─')}${title}${topFill}${borderFg('─╮')}`);
935
+ lines.push(renderTabs(innerW, border));
936
+ lines.push(
937
+ makeRow(
938
+ dim('Settings are written to ~/.pi/agent/settings.json and .pi/settings.json.'),
939
+ innerW,
940
+ border,
941
+ ),
942
+ );
943
+ lines.push(makeRow('', innerW, border));
944
+ for (const line of settingsList.render(innerW)) lines.push(makeRow(line, innerW, border));
945
+ lines.push(makeRow('', innerW, border));
946
+ const effective = resolveGoosedumpSettings(location);
947
+ lines.push(
948
+ makeRow(
949
+ dim(`Effective: turns=${formatCompactSetting(effective.turnsBeforeCompact)}`),
950
+ innerW,
951
+ border,
952
+ ),
953
+ );
954
+ lines.push(
955
+ makeRow(dim('tab switch ↑↓ select enter/space edit esc close'), innerW, border),
956
+ );
957
+ lines.push(`${borderFg('╰')}${borderFg('─'.repeat(width - 2))}${borderFg('╯')}`);
958
+ return lines;
959
+ }
960
+
763
961
  return {
764
962
  render(width: number): string[] {
765
963
  const innerW = width - 4;
@@ -770,21 +968,17 @@ async function runSessionBrowser(
770
968
  const text = (s: string) => theme.fg('text', s);
771
969
  const muted = (s: string) => theme.fg('muted', s);
772
970
 
773
- const lines: string[] = [];
774
-
775
971
  if (mode === 'compact') {
776
- const title = accent(` Compact: ${truncateToWidth(compactTitle, 40)} `);
972
+ const lines: string[] = [];
973
+ const title = accent(` Compact: ${truncateToWidth(compactTitle, 48)} `);
777
974
  const topFill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(title))));
778
975
  lines.push(`${borderFg('╭─')}${title}${topFill}${borderFg('─╮')}`);
779
976
 
780
977
  const total = compactLines.length;
781
978
  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++) {
979
+ for (const row of window) lines.push(makeRow(text(row), innerW, border));
980
+ for (let i = window.length; i < COMPACT_VIEWPORT; i++)
786
981
  lines.push(makeRow('', innerW, border));
787
- }
788
982
 
789
983
  const position =
790
984
  total > COMPACT_VIEWPORT
@@ -796,85 +990,77 @@ async function runSessionBrowser(
796
990
  return lines;
797
991
  }
798
992
 
799
- // Top border
800
- const title = accent(' Goosedump Sessions ');
993
+ if (tab === 'settings') return renderSettings(width, innerW, border);
994
+
995
+ const currentListings = locationListings();
996
+ const lines: string[] = [];
997
+ const title = accent(' Goosedump Dashboard ');
801
998
  const topFill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(title))));
802
999
  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
-
1000
+ lines.push(renderTabs(innerW, border));
1001
+ lines.push(
1002
+ makeRow(muted(`Location: ${shortPath(location, innerW - 10)}`), innerW, border),
1003
+ );
1004
+ lines.push(
1005
+ makeRow(
1006
+ muted(
1007
+ `${currentListings.length} session${currentListings.length === 1 ? '' : 's'} here`,
1008
+ ),
1009
+ innerW,
1010
+ border,
1011
+ ),
1012
+ );
808
1013
  lines.push(makeRow('', innerW, border));
809
1014
 
810
- // Session list
811
- const total = displayList.length;
812
- const window = displayList.slice(listScroll, listScroll + LIST_VIEWPORT);
1015
+ const window = currentListings.slice(listScroll, listScroll + LIST_VIEWPORT);
813
1016
  for (let i = 0; i < window.length; i++) {
814
1017
  const absolute = listScroll + i;
815
- const entry = window[i];
1018
+ const listing = window[i];
816
1019
  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
- : ' ';
1020
+ const deletePending = pendingDeletionId !== null && listing.id === pendingDeletionId;
1021
+ const cursor = deletePending ? dim('␡') : isSelected ? accent('▶') : ' ';
826
1022
  const idText = deletePending
827
- ? dim(entry.listing.id)
828
- : 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)
1023
+ ? dim(listing.id)
838
1024
  : isSelected
839
- ? accent(cwdTruncated)
840
- : muted(cwdTruncated);
841
-
842
- const line = ` ${cursor}${indent}${toggle} ${idText} ${cwdText}`;
843
- lines.push(makeRow(line, innerW, border));
1025
+ ? accent(listing.id)
1026
+ : text(listing.id);
1027
+ const label = listing.label ? ` ${listing.label}` : '';
1028
+ const count = muted(
1029
+ ` (${listing.provider}, ${listing.path ? 'resumable' : 'no file'})`,
1030
+ );
1031
+ lines.push(makeRow(` ${cursor} ${idText}${muted(label)}${count}`, innerW, border));
844
1032
  }
845
1033
 
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));
1034
+ for (let i = window.length; i < LIST_VIEWPORT; i++)
1035
+ lines.push(makeRow('', innerW, border));
1036
+
1037
+ if (currentListings.length > LIST_VIEWPORT) {
1038
+ const position = `${listScroll + 1}-${Math.min(listScroll + LIST_VIEWPORT, currentListings.length)} of ${currentListings.length}`;
1039
+ lines.push(makeRow(dim(`(${position})`), innerW, border));
849
1040
  }
850
1041
 
851
- // Deletion confirmation
852
1042
  if (pendingDeletionId) {
853
1043
  lines.push(makeRow('', innerW, border));
854
1044
  lines.push(
855
1045
  makeRow(
856
- dim(` Delete session ${pendingDeletionId}? enter=confirm esc=cancel`),
1046
+ dim(`Delete session ${pendingDeletionId}? enter=confirm esc=cancel`),
857
1047
  innerW,
858
1048
  border,
859
1049
  ),
860
1050
  );
861
1051
  }
862
1052
 
863
- // Footer
864
1053
  lines.push(makeRow('', innerW, border));
865
1054
  lines.push(
866
1055
  makeRow(
867
1056
  dim(
868
- '↑↓ navigate space/+/− fold enter compact shift+enter resume ctrl+d delete esc close',
1057
+ 'tab settings ↑↓ navigate enter compact shift+enter resume ctrl+d delete esc close',
869
1058
  ),
870
1059
  innerW,
871
1060
  border,
872
1061
  ),
873
1062
  );
874
-
875
- // Bottom border
876
1063
  lines.push(`${borderFg('╰')}${borderFg('─'.repeat(width - 2))}${borderFg('╯')}`);
877
-
878
1064
  return lines;
879
1065
  },
880
1066
 
@@ -909,11 +1095,25 @@ async function runSessionBrowser(
909
1095
  return;
910
1096
  }
911
1097
 
1098
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift(Key.tab))) {
1099
+ tab = tab === 'sessions' ? 'settings' : 'sessions';
1100
+ if (tab === 'settings') rebuildSettingsList();
1101
+ tui.requestRender();
1102
+ return;
1103
+ }
1104
+
1105
+ if (tab === 'settings') {
1106
+ settingsList.handleInput(data);
1107
+ tui.requestRender();
1108
+ return;
1109
+ }
1110
+
912
1111
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
913
1112
  done(undefined);
914
1113
  return;
915
1114
  }
916
1115
 
1116
+ const currentListings = locationListings();
917
1117
  if (matchesKey(data, Key.up)) {
918
1118
  selectedIndex = Math.max(0, selectedIndex - 1);
919
1119
  if (selectedIndex < listScroll) listScroll = selectedIndex;
@@ -922,7 +1122,7 @@ async function runSessionBrowser(
922
1122
  }
923
1123
 
924
1124
  if (matchesKey(data, Key.down)) {
925
- selectedIndex = Math.min(displayList.length - 1, selectedIndex + 1);
1125
+ selectedIndex = Math.min(Math.max(0, currentListings.length - 1), selectedIndex + 1);
926
1126
  if (selectedIndex >= listScroll + LIST_VIEWPORT) {
927
1127
  listScroll = selectedIndex - LIST_VIEWPORT + 1;
928
1128
  }
@@ -930,7 +1130,6 @@ async function runSessionBrowser(
930
1130
  return;
931
1131
  }
932
1132
 
933
- // Deletion confirmation: enter confirms, esc cancels
934
1133
  if (pendingDeletionId) {
935
1134
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
936
1135
  pendingDeletionId = null;
@@ -944,47 +1143,21 @@ async function runSessionBrowser(
944
1143
  return;
945
1144
  }
946
1145
 
947
- // Ctrl+D marks session for deletion
948
1146
  if (matchesKey(data, Key.ctrl('d'))) {
949
- const listing = displayList[selectedIndex]?.listing;
1147
+ const listing = currentListings[selectedIndex];
950
1148
  if (listing) pendingDeletionId = listing.id;
951
1149
  tui.requestRender();
952
1150
  return;
953
1151
  }
954
1152
 
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
1153
  if (matchesKey(data, Key.shift(Key.enter))) {
981
- resumePath = displayList[selectedIndex]?.listing.path;
1154
+ resumePath = currentListings[selectedIndex]?.path;
982
1155
  done(undefined);
983
1156
  return;
984
1157
  }
985
1158
 
986
1159
  if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) {
987
- const listing = displayList[selectedIndex]?.listing;
1160
+ const listing = currentListings[selectedIndex];
988
1161
  if (!listing) return;
989
1162
  compactTitle = listing.id;
990
1163
  compactScroll = 0;
@@ -999,18 +1172,11 @@ async function runSessionBrowser(
999
1172
  }
1000
1173
  mode = 'compact';
1001
1174
  tui.requestRender();
1002
- return;
1003
1175
  }
1004
1176
  },
1005
1177
 
1006
1178
  invalidate(): void {},
1007
1179
  };
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
1180
  },
1015
1181
  {
1016
1182
  overlay: true,
@@ -1032,6 +1198,56 @@ export function createGoosedumpIntegration(
1032
1198
  const shouldOverrideDefaultCompaction = options.overrideDefaultCompaction ?? true;
1033
1199
 
1034
1200
  let goosedumpAvailable = false;
1201
+ let compactSettings = DEFAULT_GOOSEDUMP_SETTINGS;
1202
+ let settingsSignature = '';
1203
+ let turnsRemaining = 0;
1204
+ let counterTriggered = false;
1205
+ let compactionQueued = false;
1206
+
1207
+ function compactSettingsSignature(settings: GoosedumpSettings): string {
1208
+ return String(settings.turnsBeforeCompact);
1209
+ }
1210
+
1211
+ function resetCompactCounters(cwd: string): void {
1212
+ compactSettings = resolveGoosedumpSettings(cwd);
1213
+ settingsSignature = compactSettingsSignature(compactSettings);
1214
+ turnsRemaining = compactSettings.turnsBeforeCompact;
1215
+ counterTriggered = false;
1216
+ }
1217
+
1218
+ function reloadCompactSettings(cwd: string): void {
1219
+ const next = resolveGoosedumpSettings(cwd);
1220
+ const nextSignature = compactSettingsSignature(next);
1221
+ if (nextSignature !== settingsSignature) resetCompactCounters(cwd);
1222
+ }
1223
+
1224
+ function noteCompletedTurn(ctx: ExtensionContext): void {
1225
+ if (!goosedumpAvailable || !shouldOverrideDefaultCompaction) return;
1226
+ reloadCompactSettings(ctx.cwd);
1227
+ if (compactSettings.turnsBeforeCompact > 0) {
1228
+ turnsRemaining -= 1;
1229
+ if (turnsRemaining <= 0) counterTriggered = true;
1230
+ }
1231
+
1232
+ if (!counterTriggered || compactionQueued) return;
1233
+ // ctx.compact() uses manual compaction semantics and aborts the current run.
1234
+ // Wait until no queued work remains before triggering plugin-driven compaction.
1235
+ if (ctx.hasPendingMessages()) return;
1236
+
1237
+ resetCompactCounters(ctx.cwd);
1238
+ compactionQueued = true;
1239
+ ctx.compact({
1240
+ onComplete: () => {
1241
+ compactionQueued = false;
1242
+ resetCompactCounters(ctx.cwd);
1243
+ },
1244
+ onError: (error) => {
1245
+ compactionQueued = false;
1246
+ const message = error instanceof Error ? error.message : String(error);
1247
+ if (ctx.hasUI) ctx.ui.notify(`goosedump compact failed: ${message}`, 'error');
1248
+ },
1249
+ });
1250
+ }
1035
1251
 
1036
1252
  function checkGoosedump(ctx?: ExtensionContext): boolean {
1037
1253
  const version = goosedumpVersion();
@@ -1323,6 +1539,7 @@ export function createGoosedumpIntegration(
1323
1539
 
1324
1540
  pi.on('session_start', async (_event, ctx) => {
1325
1541
  goosedumpAvailable = checkGoosedump(ctx);
1542
+ resetCompactCounters(ctx.cwd);
1326
1543
 
1327
1544
  if (goosedumpAvailable) {
1328
1545
  const theme = ctx.ui.theme;
@@ -1332,6 +1549,10 @@ export function createGoosedumpIntegration(
1332
1549
  }
1333
1550
  });
1334
1551
 
1552
+ pi.on('turn_end', async (_event, ctx) => {
1553
+ noteCompletedTurn(ctx);
1554
+ });
1555
+
1335
1556
  pi.on('session_before_compact', async (event, ctx) => {
1336
1557
  if (!shouldOverrideDefaultCompaction || !goosedumpAvailable) return;
1337
1558
  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.1",
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.2",
33
33
  "@sinclair/typebox": "^0.34.49"
34
34
  },
35
35
  "devDependencies": {