@gaosh3n/pi-package-manager 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,7 @@
9
9
  - `/package-manager status` to see whether updates are available
10
10
  - `/package-manager update` to run the update flow on demand
11
11
  - `/package-manager install` to install a Pi package from an entered source
12
+ - `/package-manager uninstall` to remove one or more installed Pi packages from a checkbox-style picker
12
13
  - a final result card in Pi so you can review the latest outcome
13
14
  - automatic Pi reload after a successful startup update
14
15
 
@@ -55,3 +56,13 @@ Run:
55
56
  ```
56
57
 
57
58
  Pi will prompt you for a package source such as `npm:@foo/bar` or `git:github.com/user/repo`, run the native `pi install ...` flow, and show a final result card. After a successful install, run `/reload` to activate the installed package resources.
59
+
60
+ ### Uninstall package(s)
61
+
62
+ Run:
63
+
64
+ ```text
65
+ /package-manager uninstall
66
+ ```
67
+
68
+ Pi will show a checkbox-style package picker. Use <space> to toggle package selection, then press Enter to confirm. Pi runs native `pi uninstall <source>` once per selected package and shows one final result card. After successful or partial removal, run `/reload` to deactivate removed package resources.
package/index.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  createAutoUpdateResultReport,
28
28
  createInstallResultReport,
29
29
  createReportEntryRenderer,
30
+ createUninstallResultReport,
30
31
  createStatusReport,
31
32
  formatStatusLines,
32
33
  formatUtcTimestamp,
@@ -48,12 +49,13 @@ export default function initPackageManager(
48
49
  )
49
50
 
50
51
  pi.on("session_start", controller.onSessionStart)
52
+ pi.on("session_shutdown", controller.onSessionShutdown)
51
53
 
52
54
  pi.registerCommand("package-manager", {
53
55
  description:
54
- "Manage Pi packages (usage: /package-manager [status|update|install])",
56
+ "Manage Pi packages (usage: /package-manager [status|update|install|uninstall])",
55
57
  getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
56
- const items = ["status", "update", "install"].map((value) => ({
58
+ const items = ["status", "update", "install", "uninstall"].map((value) => ({
57
59
  value,
58
60
  label: value,
59
61
  }))
@@ -84,7 +86,15 @@ export default function initPackageManager(
84
86
  return
85
87
  }
86
88
 
87
- ctx.ui.notify("Usage: /package-manager [status|update|install]", "warning")
89
+ if (subcommand === "uninstall") {
90
+ await controller.handleUninstall(ctx)
91
+ return
92
+ }
93
+
94
+ ctx.ui.notify(
95
+ "Usage: /package-manager [status|update|install|uninstall]",
96
+ "warning",
97
+ )
88
98
  },
89
99
  })
90
100
  }
@@ -97,6 +107,7 @@ export {
97
107
  createAutoUpdateRecord,
98
108
  createAutoUpdateResultReport,
99
109
  createInstallResultReport,
110
+ createUninstallResultReport,
100
111
  createStatusReport,
101
112
  formatStatusLines,
102
113
  formatUtcTimestamp,
@@ -17,27 +17,35 @@ import {
17
17
  createInstallResultReport,
18
18
  createStatusErrorReport,
19
19
  createStatusReport,
20
+ createUninstallResultReport,
20
21
  getExecDisplayOutput,
21
22
  getExecFailureDetail,
22
23
  setPackageManagerWidget,
23
24
  } from "./reports.ts"
24
25
  import { defaultPackageManagerDeps, type PackageManagerDeps } from "./runtime.ts"
26
+ import { promptForPackagesToUninstall } from "./uninstall-picker.ts"
25
27
 
26
28
  export function createPackageManagerController(
27
29
  pi: Pick<ExtensionAPI, "appendEntry" | "sendUserMessage" | "exec">,
28
30
  deps: PackageManagerDeps = defaultPackageManagerDeps,
29
31
  ) {
32
+ let sessionIsActive = true
33
+
30
34
  return {
31
35
  onSessionStart,
36
+ onSessionShutdown,
32
37
  handleStatus,
33
38
  handleUpdate,
34
39
  handleInstall,
40
+ handleUninstall,
35
41
  }
36
42
 
37
43
  async function onSessionStart(
38
44
  event: Pick<SessionStartEvent, "reason">,
39
45
  _ctx: ExtensionContext,
40
46
  ): Promise<void> {
47
+ sessionIsActive = true
48
+
41
49
  if (!shouldAutoUpdateOnSessionStart(event)) {
42
50
  return
43
51
  }
@@ -47,6 +55,10 @@ export function createPackageManagerController(
47
55
  })
48
56
  }
49
57
 
58
+ async function onSessionShutdown(): Promise<void> {
59
+ sessionIsActive = false
60
+ }
61
+
50
62
  async function handleStatus(ctx: ExtensionCommandContext): Promise<void> {
51
63
  const lastAutoUpdate = getLastAutoUpdateRecord(ctx.sessionManager.getEntries())
52
64
 
@@ -54,26 +66,37 @@ export function createPackageManagerController(
54
66
 
55
67
  try {
56
68
  const availableUpdates = await deps.checkForAvailableUpdates(ctx)
57
- pi.appendEntry(
58
- REPORT_ENTRY_TYPE,
59
- createStatusReport({
69
+
70
+ if (!isSessionActive()) {
71
+ return
72
+ }
73
+
74
+ appendReport({
75
+ type: REPORT_ENTRY_TYPE,
76
+ report: createStatusReport({
60
77
  availableUpdates,
61
78
  lastAutoUpdate,
62
79
  }),
63
- )
80
+ })
64
81
  } catch (error) {
65
- pi.appendEntry(
66
- REPORT_ENTRY_TYPE,
67
- createStatusErrorReport(
82
+ if (!isSessionActive()) {
83
+ return
84
+ }
85
+
86
+ appendReport({
87
+ type: REPORT_ENTRY_TYPE,
88
+ report: createStatusErrorReport(
68
89
  {
69
90
  availableUpdates: [],
70
91
  lastAutoUpdate,
71
92
  },
72
93
  getErrorMessage(error),
73
94
  ),
74
- )
95
+ })
75
96
  } finally {
76
- clearPackageManagerWidget(ctx)
97
+ if (isSessionActive()) {
98
+ clearPackageManagerWidget(ctx)
99
+ }
77
100
  }
78
101
  }
79
102
 
@@ -94,6 +117,10 @@ export function createPackageManagerController(
94
117
 
95
118
  const availableUpdates = await deps.checkForAvailableUpdates(ctx)
96
119
 
120
+ if (!isSessionActive()) {
121
+ return
122
+ }
123
+
97
124
  if (availableUpdates.length === 0) {
98
125
  appendSkippedResult(startedAtUtc, "No package updates are available.")
99
126
  return
@@ -105,6 +132,11 @@ export function createPackageManagerController(
105
132
  })
106
133
 
107
134
  const result = await deps.runNativeUpdate(pi, ctx)
135
+
136
+ if (!isSessionActive()) {
137
+ return
138
+ }
139
+
108
140
  const output = getExecDisplayOutput(result)
109
141
 
110
142
  if (result.code === 0) {
@@ -114,17 +146,18 @@ export function createPackageManagerController(
114
146
  outcome: "succeeded",
115
147
  packagesUpdated: availableUpdates.length,
116
148
  })
117
-
118
- appendAutoUpdateRecordAndReport(
119
- pi,
149
+ const report = createAutoUpdateResultReport({
120
150
  record,
121
- createAutoUpdateResultReport({
122
- record,
123
- output,
124
- reloadAfterSeconds: RELOAD_COUNTDOWN_SECONDS,
125
- }),
126
- )
127
- await runReloadCountdown(ctx)
151
+ output,
152
+ reloadAfterSeconds: RELOAD_COUNTDOWN_SECONDS,
153
+ })
154
+
155
+ appendAutoUpdateRecordAndReport(pi, record, report)
156
+
157
+ if (!(await runReloadCountdown(ctx))) {
158
+ return
159
+ }
160
+
128
161
  clearPackageManagerWidget(ctx)
129
162
  shouldClearWidget = false
130
163
  await ctx.reload()
@@ -136,16 +169,17 @@ export function createPackageManagerController(
136
169
  endedAtUtc: deps.nowIso(),
137
170
  outcome: "failed",
138
171
  packagesUpdated: 0,
139
- reason: getExecFailureDetail(result),
172
+ reason: getExecFailureDetail(result, "Package update command failed."),
140
173
  })
174
+ const report = createAutoUpdateResultReport({ record, output })
141
175
 
142
- appendAutoUpdateRecordAndReport(
143
- pi,
144
- record,
145
- createAutoUpdateResultReport({ record, output }),
146
- )
176
+ appendAutoUpdateRecordAndReport(pi, record, report)
147
177
  notifyStartupFailure(ctx, options.startupTriggered)
148
178
  } catch (error) {
179
+ if (!isSessionActive()) {
180
+ return
181
+ }
182
+
149
183
  const record = createAutoUpdateRecord({
150
184
  startedAtUtc,
151
185
  endedAtUtc: deps.nowIso(),
@@ -153,15 +187,12 @@ export function createPackageManagerController(
153
187
  packagesUpdated: 0,
154
188
  reason: getErrorMessage(error),
155
189
  })
190
+ const report = createAutoUpdateResultReport({ record })
156
191
 
157
- appendAutoUpdateRecordAndReport(
158
- pi,
159
- record,
160
- createAutoUpdateResultReport({ record }),
161
- )
192
+ appendAutoUpdateRecordAndReport(pi, record, report)
162
193
  notifyStartupFailure(ctx, options.startupTriggered)
163
194
  } finally {
164
- if (shouldClearWidget) {
195
+ if (shouldClearWidget && isSessionActive()) {
165
196
  clearPackageManagerWidget(ctx)
166
197
  }
167
198
  }
@@ -183,7 +214,7 @@ export function createPackageManagerController(
183
214
  )
184
215
  )?.trim()
185
216
 
186
- if (source === undefined) {
217
+ if (!isSessionActive() || source === undefined) {
187
218
  return
188
219
  }
189
220
 
@@ -197,46 +228,180 @@ export function createPackageManagerController(
197
228
 
198
229
  try {
199
230
  const result = await deps.runNativeInstall(pi, ctx, source)
231
+
232
+ if (!isSessionActive()) {
233
+ return
234
+ }
235
+
200
236
  const output = getExecDisplayOutput(result)
237
+ const report = createInstallResultReport({
238
+ startedAtUtc,
239
+ endedAtUtc: deps.nowIso(),
240
+ source,
241
+ outcome: result.code === 0 ? "succeeded" : "failed",
242
+ output,
243
+ reason:
244
+ result.code === 0
245
+ ? undefined
246
+ : getExecFailureDetail(
247
+ result,
248
+ "Package install command failed.",
249
+ ),
250
+ })
201
251
 
202
- if (result.code === 0) {
203
- pi.appendEntry(
204
- REPORT_ENTRY_TYPE,
205
- createInstallResultReport({
206
- startedAtUtc,
207
- endedAtUtc: deps.nowIso(),
208
- source,
209
- outcome: "succeeded",
210
- output,
211
- }),
212
- )
252
+ appendReport({ type: REPORT_ENTRY_TYPE, report })
253
+ } catch (error) {
254
+ if (!isSessionActive()) {
213
255
  return
214
256
  }
215
257
 
216
- pi.appendEntry(
217
- REPORT_ENTRY_TYPE,
218
- createInstallResultReport({
258
+ appendReport({
259
+ type: REPORT_ENTRY_TYPE,
260
+ report: createInstallResultReport({
219
261
  startedAtUtc,
220
262
  endedAtUtc: deps.nowIso(),
221
263
  source,
222
264
  outcome: "failed",
223
- output,
224
- reason: getExecFailureDetail(result),
265
+ reason: getErrorMessage(error),
225
266
  }),
226
- )
227
- } catch (error) {
228
- pi.appendEntry(
229
- REPORT_ENTRY_TYPE,
230
- createInstallResultReport({
267
+ })
268
+ } finally {
269
+ if (isSessionActive()) {
270
+ clearPackageManagerWidget(ctx)
271
+ }
272
+ }
273
+ }
274
+
275
+ async function handleUninstall(ctx: ExtensionCommandContext): Promise<void> {
276
+ if (ctx.mode !== "tui") {
277
+ ctx.ui.notify("/package-manager uninstall requires TUI mode.", "warning")
278
+ return
279
+ }
280
+
281
+ const packages = await deps.listConfiguredPackages(ctx)
282
+
283
+ if (!isSessionActive()) {
284
+ return
285
+ }
286
+
287
+ if (packages.length === 0) {
288
+ ctx.ui.notify("No Pi packages are available to uninstall.", "info")
289
+ return
290
+ }
291
+
292
+ const selectedSources = await promptForPackagesToUninstall(ctx, packages)
293
+
294
+ if (!isSessionActive() || selectedSources === undefined) {
295
+ return
296
+ }
297
+
298
+ if (selectedSources.length === 0) {
299
+ ctx.ui.notify("Select at least one package to uninstall.", "warning")
300
+ return
301
+ }
302
+
303
+ const selectedSourceSet = new Set(selectedSources)
304
+ const sources = packages
305
+ .map((pkg) => pkg.source)
306
+ .filter((source) => selectedSourceSet.has(source))
307
+
308
+ if (sources.length === 0) {
309
+ ctx.ui.notify("Select at least one package to uninstall.", "warning")
310
+ return
311
+ }
312
+
313
+ const startedAtUtc = deps.nowIso()
314
+ const succeededSources: string[] = []
315
+ const failedSources: string[] = []
316
+ const outputSections: string[] = []
317
+
318
+ try {
319
+ for (const [index, source] of sources.entries()) {
320
+ setPackageManagerWidget(ctx, {
321
+ mode: "package-uninstalling",
322
+ current: index + 1,
323
+ total: sources.length,
324
+ source,
325
+ })
326
+
327
+ const result = await deps.runNativeUninstall(pi, ctx, source)
328
+
329
+ if (!isSessionActive()) {
330
+ return
331
+ }
332
+
333
+ const output = getExecDisplayOutput(result)
334
+
335
+ if (result.code === 0) {
336
+ if (output) {
337
+ outputSections.push(`[${source}]\n${output}`)
338
+ }
339
+ succeededSources.push(source)
340
+ continue
341
+ }
342
+
343
+ failedSources.push(source)
344
+ outputSections.push(
345
+ `[${source}]\n${output ?? getExecFailureDetail(result, "Package uninstall command failed.")}`,
346
+ )
347
+ }
348
+
349
+ appendReport({
350
+ type: REPORT_ENTRY_TYPE,
351
+ report: createUninstallResultReport({
231
352
  startedAtUtc,
232
353
  endedAtUtc: deps.nowIso(),
233
- source,
234
- outcome: "failed",
235
- reason: getErrorMessage(error),
354
+ sources,
355
+ outcome:
356
+ failedSources.length === 0
357
+ ? "succeeded"
358
+ : succeededSources.length > 0
359
+ ? "partial"
360
+ : "failed",
361
+ succeededSources,
362
+ failedSources,
363
+ output:
364
+ outputSections.length > 0
365
+ ? outputSections.join("\n\n")
366
+ : undefined,
367
+ reason:
368
+ failedSources.length > 0
369
+ ? `Failed to uninstall ${failedSources[0]}.`
370
+ : undefined,
236
371
  }),
372
+ })
373
+ } catch (error) {
374
+ if (!isSessionActive()) {
375
+ return
376
+ }
377
+
378
+ const failedSource = sources[succeededSources.length]
379
+ const errorMessage = getErrorMessage(error)
380
+
381
+ if (failedSource && !failedSources.includes(failedSource)) {
382
+ failedSources.push(failedSource)
383
+ }
384
+
385
+ outputSections.push(
386
+ failedSource ? `[${failedSource}]\n${errorMessage}` : errorMessage,
237
387
  )
388
+ appendReport({
389
+ type: REPORT_ENTRY_TYPE,
390
+ report: createUninstallResultReport({
391
+ startedAtUtc,
392
+ endedAtUtc: deps.nowIso(),
393
+ sources,
394
+ outcome: succeededSources.length > 0 ? "partial" : "failed",
395
+ succeededSources,
396
+ failedSources,
397
+ output: outputSections.join("\n\n"),
398
+ reason: errorMessage,
399
+ }),
400
+ })
238
401
  } finally {
239
- clearPackageManagerWidget(ctx)
402
+ if (isSessionActive()) {
403
+ clearPackageManagerWidget(ctx)
404
+ }
240
405
  }
241
406
  }
242
407
 
@@ -248,26 +413,44 @@ export function createPackageManagerController(
248
413
  packagesUpdated: 0,
249
414
  reason,
250
415
  })
416
+ const report = createAutoUpdateResultReport({ record })
251
417
 
252
- appendAutoUpdateRecordAndReport(
253
- pi,
254
- record,
255
- createAutoUpdateResultReport({ record }),
256
- )
418
+ appendAutoUpdateRecordAndReport(pi, record, report)
419
+ }
420
+
421
+ function appendReport(entry: {
422
+ type: string
423
+ report: ReturnType<typeof createStatusReport>
424
+ }): void {
425
+ pi.appendEntry(entry.type, entry.report)
426
+ }
427
+
428
+ function isSessionActive(): boolean {
429
+ return sessionIsActive
257
430
  }
258
431
 
259
432
  async function runReloadCountdown(
260
433
  ctx: ExtensionContext,
261
434
  seconds = RELOAD_COUNTDOWN_SECONDS,
262
- ): Promise<void> {
435
+ ): Promise<boolean> {
263
436
  for (let remaining = seconds; remaining >= 1; remaining--) {
437
+ if (!isSessionActive()) {
438
+ return false
439
+ }
440
+
264
441
  setPackageManagerWidget(ctx, {
265
442
  mode: "countdown",
266
443
  secondsRemaining: remaining,
267
444
  })
268
445
 
269
446
  await deps.sleep(1000)
447
+
448
+ if (!isSessionActive()) {
449
+ return false
450
+ }
270
451
  }
452
+
453
+ return true
271
454
  }
272
455
  }
273
456
 
package/internal/model.ts CHANGED
@@ -6,9 +6,11 @@ export const RELOAD_COUNTDOWN_SECONDS = 5
6
6
  export const OUTPUT_PREVIEW_LINE_COUNT = 8
7
7
  export const UPDATE_COMMAND = ["update", "--extensions"] as const
8
8
  export const INSTALL_COMMAND = ["install"] as const
9
+ export const UNINSTALL_COMMAND = ["uninstall"] as const
9
10
 
10
11
  export type AutoUpdateOutcome = "succeeded" | "failed" | "skipped"
11
12
  export type InstallOutcome = "succeeded" | "failed"
13
+ export type UninstallOutcome = "succeeded" | "partial" | "failed"
12
14
  export type ReportTone = "info" | "success" | "warning" | "error"
13
15
 
14
16
  export type WidgetState =
@@ -17,6 +19,12 @@ export type WidgetState =
17
19
  | { mode: "installing"; packages: number }
18
20
  | { mode: "countdown"; secondsRemaining: number }
19
21
  | { mode: "package-installing"; source: string }
22
+ | {
23
+ mode: "package-uninstalling"
24
+ current: number
25
+ total: number
26
+ source: string
27
+ }
20
28
 
21
29
  export interface AutoUpdateRecord {
22
30
  startedAtUtc: string
@@ -43,3 +51,9 @@ export interface PackageStatusSnapshot {
43
51
  availableUpdates: string[]
44
52
  lastAutoUpdate?: AutoUpdateRecord
45
53
  }
54
+
55
+ export interface ConfiguredPackageOption {
56
+ source: string
57
+ scope: "user" | "project"
58
+ filtered: boolean
59
+ }
@@ -14,6 +14,7 @@ import {
14
14
  type InstallOutcome,
15
15
  type PackageManagerReport,
16
16
  type PackageStatusSnapshot,
17
+ type UninstallOutcome,
17
18
  type WidgetState,
18
19
  } from "./model.ts"
19
20
 
@@ -35,81 +36,7 @@ export function createReportEntryRenderer() {
35
36
  return undefined
36
37
  }
37
38
 
38
- const toneColor =
39
- report.tone === "error"
40
- ? "error"
41
- : report.tone === "warning"
42
- ? "warning"
43
- : report.tone === "success"
44
- ? "success"
45
- : "accent"
46
- const lineTextTone = report.lineTone === "dim" ? "dim" : "customMessageText"
47
- const outputTextTone = report.outputTone === "dim" ? "dim" : lineTextTone
48
-
49
- const box = new Box(1, 1, (text: string) => theme.bg("customMessageBg", text))
50
-
51
- box.addChild(
52
- new Text(
53
- `${theme.fg(toneColor, "●")} ${theme.bold(theme.fg("customMessageLabel", report.title))}`,
54
- 0,
55
- 0,
56
- ),
57
- )
58
-
59
- if (report.headline) {
60
- box.addChild(new Text(theme.fg("text", report.headline), 0, 0))
61
- }
62
-
63
- if (report.lines.length > 0) {
64
- box.addChild(
65
- new Text(
66
- report.lines.map((line) => theme.fg(lineTextTone, line)).join("\n"),
67
- 0,
68
- 0,
69
- ),
70
- )
71
- }
72
-
73
- if (report.output?.trim()) {
74
- if (!expanded && report.hideOutputWhenCollapsed) {
75
- box.addChild(
76
- new Text(
77
- formatExpandHint(
78
- theme,
79
- `to expand to see ${report.outputDescription ?? "output"}.`,
80
- ),
81
- 0,
82
- 0,
83
- ),
84
- )
85
- } else {
86
- const { text, truncated } = formatReportOutput(report.output, expanded)
87
-
88
- box.addChild(
89
- new Text(
90
- theme.fg(outputTextTone, report.outputLabel ?? "Output:"),
91
- 0,
92
- 0,
93
- ),
94
- )
95
- box.addChild(new Text(theme.fg(outputTextTone, text), 0, 0))
96
-
97
- if (truncated) {
98
- box.addChild(
99
- new Text(
100
- formatExpandHint(
101
- theme,
102
- `to expand to view the full ${report.outputDescription ?? "output"}.`,
103
- ),
104
- 0,
105
- 0,
106
- ),
107
- )
108
- }
109
- }
110
- }
111
-
112
- return box
39
+ return createReportBox(report, theme, { expanded })
113
40
  }
114
41
  }
115
42
 
@@ -246,6 +173,53 @@ export function createInstallResultReport(input: {
246
173
  }
247
174
  }
248
175
 
176
+ export function createUninstallResultReport(input: {
177
+ startedAtUtc: string
178
+ endedAtUtc: string
179
+ sources: string[]
180
+ outcome: UninstallOutcome
181
+ succeededSources: string[]
182
+ failedSources: string[]
183
+ output?: string
184
+ reason?: string
185
+ }): PackageManagerReport {
186
+ return {
187
+ title: PACKAGE_MANAGER_TITLE,
188
+ headline:
189
+ input.outcome === "succeeded"
190
+ ? "Pi package uninstall completed."
191
+ : input.outcome === "partial"
192
+ ? "Pi package uninstall partially completed."
193
+ : "Pi package uninstall failed.",
194
+ tone:
195
+ input.outcome === "succeeded"
196
+ ? "success"
197
+ : input.outcome === "partial"
198
+ ? "warning"
199
+ : "error",
200
+ lines: [
201
+ `Start: ${formatUtcTimestamp(input.startedAtUtc)}`,
202
+ `End: ${formatUtcTimestamp(input.endedAtUtc)}`,
203
+ `Result: ${input.outcome}`,
204
+ `Packages selected: ${input.sources.length}`,
205
+ ...input.sources.map((source) => `Package source: ${source}`),
206
+ `Packages removed: ${input.succeededSources.length}`,
207
+ ...(input.failedSources.length > 0
208
+ ? [`Packages failed: ${input.failedSources.length}`]
209
+ : ["Run /reload to deactivate removed package resources."]),
210
+ ...(input.reason ? [`Latest failure detail: ${input.reason}`] : []),
211
+ ],
212
+ lineTone: "dim",
213
+ output: input.output,
214
+ outputLabel:
215
+ input.outcome === "succeeded" ? "Uninstall output:" : "Error detail:",
216
+ outputDescription:
217
+ input.outcome === "succeeded" ? "uninstall output" : "error detail",
218
+ outputTone: "dim",
219
+ hideOutputWhenCollapsed: true,
220
+ }
221
+ }
222
+
249
223
  export function createAutomaticUpdateWidgetLines(state: WidgetState): string[] {
250
224
  if (state.mode === "status-checking") {
251
225
  return ["Pi package status in progress.", "Checking for package updates..."]
@@ -272,6 +246,13 @@ export function createAutomaticUpdateWidgetLines(state: WidgetState): string[] {
272
246
  ]
273
247
  }
274
248
 
249
+ if (state.mode === "package-uninstalling") {
250
+ return [
251
+ "Pi package uninstall in progress.",
252
+ `Removing ${state.current}/${state.total}: ${state.source}`,
253
+ ]
254
+ }
255
+
275
256
  return [
276
257
  formatAutomaticUpdateHeadline("succeeded"),
277
258
  `Reloading in ${state.secondsRemaining} second${state.secondsRemaining === 1 ? "" : "s"} to activate updated package resources.`,
@@ -288,39 +269,12 @@ export function setPackageManagerWidget(
288
269
 
289
270
  ctx.ui.setWidget(
290
271
  PACKAGE_MANAGER_WIDGET_KEY,
291
- (_tui, theme) => {
292
- const background =
293
- state.mode === "countdown"
294
- ? (text: string) => theme.bg("toolSuccessBg", text)
295
- : (text: string) => theme.bg("toolPendingBg", text)
296
- const box = new Box(1, 1, background)
297
- const titleColor = state.mode === "countdown" ? "success" : "accent"
298
- const bodyLines = createAutomaticUpdateWidgetLines(state)
299
-
300
- box.addChild(
301
- new Text(
302
- `${theme.fg(titleColor, "●")} ${theme.bold(theme.fg("customMessageLabel", PACKAGE_MANAGER_TITLE))}`,
303
- 0,
304
- 0,
305
- ),
306
- )
307
- box.addChild(
308
- new Text(
309
- bodyLines
310
- .map((line, index) =>
311
- theme.fg(
312
- index === bodyLines.length - 1 ? "dim" : "text",
313
- line,
314
- ),
315
- )
316
- .join("\n"),
317
- 0,
318
- 0,
319
- ),
320
- )
321
-
322
- return box
323
- },
272
+ (_tui, theme) =>
273
+ createStatusBox(theme, createAutomaticUpdateWidgetLines(state), {
274
+ titleColor: state.mode === "countdown" ? "success" : "accent",
275
+ background:
276
+ state.mode === "countdown" ? "toolSuccessBg" : "toolPendingBg",
277
+ }),
324
278
  { placement: "aboveEditor" },
325
279
  )
326
280
  }
@@ -349,10 +303,13 @@ export function getExecDisplayOutput(result: ExecResult): string | undefined {
349
303
  return sections.length > 0 ? sections.join("\n\n") : undefined
350
304
  }
351
305
 
352
- export function getExecFailureDetail(result: ExecResult): string {
306
+ export function getExecFailureDetail(
307
+ result: ExecResult,
308
+ fallback = "Package command failed.",
309
+ ): string {
353
310
  const stderr = result.stderr.trim()
354
311
  const stdout = result.stdout.trim()
355
- const detail = stderr || stdout || "Package update command failed."
312
+ const detail = stderr || stdout || fallback
356
313
 
357
314
  return detail.length > 400 ? `${detail.slice(0, 397)}...` : detail
358
315
  }
@@ -370,6 +327,115 @@ function formatAutomaticUpdateHeadline(
370
327
  return `Pi package(s) update ${suffix}.`
371
328
  }
372
329
 
330
+ function createReportBox(
331
+ report: PackageManagerReport,
332
+ theme: ThemeLike,
333
+ options: { expanded: boolean },
334
+ ): Box {
335
+ const toneColor =
336
+ report.tone === "error"
337
+ ? "error"
338
+ : report.tone === "warning"
339
+ ? "warning"
340
+ : report.tone === "success"
341
+ ? "success"
342
+ : "accent"
343
+ const lineTextTone = report.lineTone === "dim" ? "dim" : "customMessageText"
344
+ const outputTextTone = report.outputTone === "dim" ? "dim" : lineTextTone
345
+ const box = new Box(1, 1, (text: string) => theme.bg("customMessageBg", text))
346
+
347
+ box.addChild(
348
+ new Text(
349
+ `${theme.fg(toneColor, "●")} ${theme.bold(theme.fg("customMessageLabel", report.title))}`,
350
+ 0,
351
+ 0,
352
+ ),
353
+ )
354
+
355
+ if (report.headline) {
356
+ box.addChild(new Text(theme.fg("text", report.headline), 0, 0))
357
+ }
358
+
359
+ if (report.lines.length > 0) {
360
+ box.addChild(
361
+ new Text(
362
+ report.lines.map((line) => theme.fg(lineTextTone, line)).join("\n"),
363
+ 0,
364
+ 0,
365
+ ),
366
+ )
367
+ }
368
+
369
+ if (!report.output?.trim()) {
370
+ return box
371
+ }
372
+
373
+ if (!options.expanded && report.hideOutputWhenCollapsed) {
374
+ box.addChild(
375
+ new Text(
376
+ formatExpandHint(
377
+ theme,
378
+ `to expand to see ${report.outputDescription ?? "output"}.`,
379
+ ),
380
+ 0,
381
+ 0,
382
+ ),
383
+ )
384
+ return box
385
+ }
386
+
387
+ const { text, truncated } = formatReportOutput(report.output, options.expanded)
388
+
389
+ box.addChild(
390
+ new Text(theme.fg(outputTextTone, report.outputLabel ?? "Output:"), 0, 0),
391
+ )
392
+ box.addChild(new Text(theme.fg(outputTextTone, text), 0, 0))
393
+
394
+ if (truncated) {
395
+ box.addChild(
396
+ new Text(
397
+ formatExpandHint(
398
+ theme,
399
+ `to expand to view the full ${report.outputDescription ?? "output"}.`,
400
+ ),
401
+ 0,
402
+ 0,
403
+ ),
404
+ )
405
+ }
406
+
407
+ return box
408
+ }
409
+
410
+ function createStatusBox(
411
+ theme: ThemeLike,
412
+ bodyLines: string[],
413
+ options: { titleColor: string; background: string },
414
+ ): Box {
415
+ const box = new Box(1, 1, (text: string) => theme.bg(options.background, text))
416
+
417
+ box.addChild(
418
+ new Text(
419
+ `${theme.fg(options.titleColor, "●")} ${theme.bold(theme.fg("customMessageLabel", PACKAGE_MANAGER_TITLE))}`,
420
+ 0,
421
+ 0,
422
+ ),
423
+ )
424
+ box.addChild(
425
+ new Text(
426
+ bodyLines
427
+ .map((line, index) =>
428
+ theme.fg(index === bodyLines.length - 1 ? "dim" : "text", line),
429
+ )
430
+ .join("\n"),
431
+ 0,
432
+ 0,
433
+ ),
434
+ )
435
+
436
+ return box
437
+ }
438
+
373
439
  function formatExpandHint(theme: ThemeLike, description: string): string {
374
440
  const expandKey = keyText("app.tools.expand") || "Ctrl+O"
375
441
 
@@ -8,13 +8,19 @@ import {
8
8
  type ExtensionContext,
9
9
  } from "@earendil-works/pi-coding-agent"
10
10
 
11
- import { INSTALL_COMMAND, UPDATE_COMMAND } from "./model.ts"
11
+ import {
12
+ INSTALL_COMMAND,
13
+ UNINSTALL_COMMAND,
14
+ UPDATE_COMMAND,
15
+ type ConfiguredPackageOption,
16
+ } from "./model.ts"
12
17
 
13
18
  export interface PackageManagerDeps {
14
19
  nowIso(): string
15
20
  sleep(milliseconds: number): Promise<void>
16
21
  isOffline(): boolean
17
22
  checkForAvailableUpdates(ctx: ExtensionContext): Promise<string[]>
23
+ listConfiguredPackages(ctx: ExtensionContext): Promise<ConfiguredPackageOption[]>
18
24
  runNativeUpdate(
19
25
  pi: Pick<ExtensionAPI, "exec">,
20
26
  ctx: ExtensionCommandContext,
@@ -24,6 +30,11 @@ export interface PackageManagerDeps {
24
30
  ctx: ExtensionCommandContext,
25
31
  source: string,
26
32
  ): Promise<ExecResult>
33
+ runNativeUninstall(
34
+ pi: Pick<ExtensionAPI, "exec">,
35
+ ctx: ExtensionCommandContext,
36
+ source: string,
37
+ ): Promise<ExecResult>
27
38
  }
28
39
 
29
40
  export const defaultPackageManagerDeps: PackageManagerDeps = {
@@ -35,21 +46,25 @@ export const defaultPackageManagerDeps: PackageManagerDeps = {
35
46
  },
36
47
  isOffline: () => Boolean(process.env.PI_OFFLINE),
37
48
  async checkForAvailableUpdates(ctx: ExtensionContext): Promise<string[]> {
38
- const agentDir = getAgentDir()
39
- const settingsManager = SettingsManager.create(ctx.cwd, agentDir, {
40
- projectTrusted: ctx.isProjectTrusted(),
41
- })
42
- const packageManager = new DefaultPackageManager({
43
- cwd: ctx.cwd,
44
- agentDir,
45
- settingsManager,
46
- })
49
+ const packageManager = createDefaultPackageManager(ctx)
47
50
  const updates = await packageManager.checkForAvailableUpdates()
48
51
 
49
52
  return updates
50
53
  .map((update) => update.displayName)
51
54
  .sort((left, right) => left.localeCompare(right))
52
55
  },
56
+ async listConfiguredPackages(
57
+ ctx: ExtensionContext,
58
+ ): Promise<ConfiguredPackageOption[]> {
59
+ return createDefaultPackageManager(ctx)
60
+ .listConfiguredPackages()
61
+ .map((pkg) => ({
62
+ source: pkg.source,
63
+ scope: pkg.scope,
64
+ filtered: pkg.filtered,
65
+ }))
66
+ .sort((left, right) => left.source.localeCompare(right.source))
67
+ },
53
68
  runNativeUpdate(pi: Pick<ExtensionAPI, "exec">, ctx: ExtensionCommandContext) {
54
69
  return pi.exec("pi", [...UPDATE_COMMAND], {
55
70
  cwd: ctx.cwd,
@@ -66,4 +81,27 @@ export const defaultPackageManagerDeps: PackageManagerDeps = {
66
81
  signal: ctx.signal,
67
82
  })
68
83
  },
84
+ runNativeUninstall(
85
+ pi: Pick<ExtensionAPI, "exec">,
86
+ ctx: ExtensionCommandContext,
87
+ source: string,
88
+ ) {
89
+ return pi.exec("pi", [...UNINSTALL_COMMAND, source], {
90
+ cwd: ctx.cwd,
91
+ signal: ctx.signal,
92
+ })
93
+ },
94
+ }
95
+
96
+ function createDefaultPackageManager(ctx: ExtensionContext): DefaultPackageManager {
97
+ const agentDir = getAgentDir()
98
+ const settingsManager = SettingsManager.create(ctx.cwd, agentDir, {
99
+ projectTrusted: ctx.isProjectTrusted(),
100
+ })
101
+
102
+ return new DefaultPackageManager({
103
+ cwd: ctx.cwd,
104
+ agentDir,
105
+ settingsManager,
106
+ })
69
107
  }
@@ -0,0 +1,144 @@
1
+ import {
2
+ DynamicBorder,
3
+ type ExtensionCommandContext,
4
+ type Theme,
5
+ } from "@earendil-works/pi-coding-agent"
6
+ import {
7
+ Container,
8
+ Key,
9
+ SelectList,
10
+ Text,
11
+ type Component,
12
+ type SelectItem,
13
+ type TUI,
14
+ matchesKey,
15
+ } from "@earendil-works/pi-tui"
16
+
17
+ import type { ConfiguredPackageOption } from "./model.ts"
18
+
19
+ export async function promptForPackagesToUninstall(
20
+ ctx: Pick<ExtensionCommandContext, "ui">,
21
+ packages: ConfiguredPackageOption[],
22
+ ): Promise<string[] | undefined> {
23
+ return ctx.ui.custom<string[] | undefined>(
24
+ (tui, theme, _keybindings, done) => {
25
+ const picker = new PackageUninstallPicker(tui, theme, packages)
26
+ picker.done = done
27
+ return picker
28
+ },
29
+ {
30
+ overlay: true,
31
+ overlayOptions: {
32
+ width: "70%",
33
+ minWidth: 48,
34
+ maxHeight: "80%",
35
+ },
36
+ },
37
+ )
38
+ }
39
+
40
+ class PackageUninstallPicker implements Component {
41
+ private readonly container = new Container()
42
+ private readonly title = new Text("", 1, 0)
43
+ private readonly summary = new Text("", 1, 0)
44
+ private readonly footer = new Text("", 1, 0)
45
+ private readonly checkedSources = new Set<string>()
46
+ private readonly items: SelectItem[]
47
+ private readonly selectList: SelectList
48
+ private readonly tui: TUI
49
+ private readonly theme: Theme
50
+
51
+ constructor(tui: TUI, theme: Theme, packages: ConfiguredPackageOption[]) {
52
+ this.tui = tui
53
+ this.theme = theme
54
+ this.items = packages.map((pkg) => ({
55
+ value: pkg.source,
56
+ label: formatPackageOptionLabel(pkg.source, false),
57
+ }))
58
+ this.selectList = new SelectList(this.items, Math.min(this.items.length, 10), {
59
+ selectedPrefix: (text) => this.theme.fg("accent", text),
60
+ selectedText: (text) => this.theme.fg("accent", text),
61
+ description: (text) => this.theme.fg("muted", text),
62
+ scrollInfo: (text) => this.theme.fg("dim", text),
63
+ noMatch: (text) => this.theme.fg("warning", text),
64
+ })
65
+
66
+ this.selectList.onSelect = () => {
67
+ this.done?.(Array.from(this.checkedSources))
68
+ }
69
+ this.selectList.onCancel = () => {
70
+ this.done?.(undefined)
71
+ }
72
+
73
+ this.container.addChild(
74
+ new DynamicBorder((text: string) => this.theme.fg("accent", text)),
75
+ )
76
+ this.container.addChild(this.title)
77
+ this.container.addChild(this.summary)
78
+ this.container.addChild(this.selectList)
79
+ this.container.addChild(this.footer)
80
+ this.container.addChild(
81
+ new DynamicBorder((text: string) => this.theme.fg("accent", text)),
82
+ )
83
+
84
+ this.refreshText()
85
+ }
86
+
87
+ done?: (value: string[] | undefined) => void
88
+
89
+ handleInput(data: string): void {
90
+ if (matchesKey(data, Key.space)) {
91
+ this.toggleSelectedItem()
92
+ this.tui.requestRender()
93
+ return
94
+ }
95
+
96
+ this.selectList.handleInput(data)
97
+ this.tui.requestRender()
98
+ }
99
+
100
+ invalidate(): void {
101
+ this.container.invalidate()
102
+ this.refreshText()
103
+ }
104
+
105
+ render(width: number): string[] {
106
+ return this.container.render(width)
107
+ }
108
+
109
+ private refreshText(): void {
110
+ this.title.setText(
111
+ this.theme.fg("accent", this.theme.bold("Select Pi Packages to Uninstall")),
112
+ )
113
+ this.summary.setText(
114
+ this.theme.fg(
115
+ "dim",
116
+ `${this.checkedSources.size} selected • enter confirm • esc cancel`,
117
+ ),
118
+ )
119
+ this.footer.setText(this.theme.fg("dim", "↑↓ navigate • space toggle checkbox"))
120
+ }
121
+
122
+ private toggleSelectedItem(): void {
123
+ const selectedItem = this.selectList.getSelectedItem()
124
+
125
+ if (!selectedItem) {
126
+ return
127
+ }
128
+
129
+ if (this.checkedSources.has(selectedItem.value)) {
130
+ this.checkedSources.delete(selectedItem.value)
131
+ selectedItem.label = formatPackageOptionLabel(selectedItem.value, false)
132
+ } else {
133
+ this.checkedSources.add(selectedItem.value)
134
+ selectedItem.label = formatPackageOptionLabel(selectedItem.value, true)
135
+ }
136
+
137
+ this.selectList.invalidate()
138
+ this.refreshText()
139
+ }
140
+ }
141
+
142
+ function formatPackageOptionLabel(source: string, checked: boolean): string {
143
+ return `${checked ? "[x]" : "[ ]"} ${source}`
144
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaosh3n/pi-package-manager",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Package Manager for Pi",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.23.0",