@pi-unipi/unipi 2.0.2 → 2.0.3

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
@@ -1,6 +1,6 @@
1
1
  # Unipi
2
2
 
3
- 18 packages that turn Pi into a full development workstation. Structured workflows, persistent memory, parallel agents, web research, notifications, context management, and a live status bar — all wired together through a shared event system.
3
+ 20 workspace packages that turn Pi into a full development workstation. Structured workflows, persistent memory, parallel agents, web research, notifications, context management, command autocomplete, and a live status bar — all wired together through a shared event system.
4
4
 
5
5
  One command installs everything:
6
6
  ```bash
@@ -17,6 +17,8 @@ pi install npm:@pi-unipi/unipi
17
17
 
18
18
  **[Compactor](./packages/compactor/README.md)** — Zero-LLM context engine. 6-stage pipeline hits 95%+ token reduction at zero API cost. Session continuity, sandbox execution, FTS5 search.
19
19
 
20
+ **[CocoIndex](./packages/cocoindex/README.md)** — Project indexing and semantic code search backed by CocoIndex and LanceDB. Agent tools and slash commands for status, init, update, and search.
21
+
20
22
  **[Subagents](./packages/subagents/README.md)** — Parallel execution with file locking. Spawn background agents to research, fix, or build while the main agent keeps going.
21
23
 
22
24
  **[Web API](./packages/web-api/README.md)** — Web search, page reading, content summarization. Smart-fetch engine with browser-grade TLS fingerprinting — free, no API key. Paid providers as fallbacks.
@@ -43,6 +45,8 @@ pi install npm:@pi-unipi/unipi
43
45
 
44
46
  **[Input Shortcuts](./packages/input-shortcuts/README.md)** — Keyboard shortcuts via vim-style chord overlay. Stash/restore, undo/redo, clipboard, thinking toggle.
45
47
 
48
+ **[Command Enchantment](./packages/autocomplete/README.md)** — Enhanced `/unipi:*` autocomplete with package grouping, descriptions, colors, and registry audits that catch stale command docs before release.
49
+
46
50
  ## Architecture
47
51
 
48
52
  Packages discover each other through events, not direct imports. Core defines the event types and constants. Every package emits `MODULE_READY` on load and subscribes to events it cares about.
@@ -78,6 +82,7 @@ Coexists triggers enhance behavior when packages are installed together. Workflo
78
82
  | Ralph | `/unipi:ralph` | start, stop, resume, status |
79
83
  | Memory | `/unipi:memory-` | process, search, consolidate, forget |
80
84
  | Compactor | `/unipi:` | lossless-compact, session-recall, compact-stats, compact-settings, compact-preset, compact-help |
85
+ | CocoIndex | `/unipi:cocoindex-` | init, update, status, search, settings |
81
86
  | Notify | `/unipi:notify-` | settings, test, set-tg, set-ntfy |
82
87
  | MCP | `/unipi:mcp-` | add, settings, sync, status |
83
88
  | Web | `/unipi:web-` | settings, cache-clear |
@@ -121,6 +126,7 @@ unipi/
121
126
  │ ├── ralph/ # Iterative loops
122
127
  │ ├── memory/ # SQLite + vector search
123
128
  │ ├── compactor/ # Context engine
129
+ │ ├── cocoindex/ # Project indexing and semantic search
124
130
  │ ├── subagents/ # Parallel execution
125
131
  │ ├── web-api/ # Web research
126
132
  │ ├── mcp/ # MCP server integration
@@ -134,6 +140,7 @@ unipi/
134
140
  │ ├── utility/ # Diagnostics, diff rendering
135
141
  │ ├── updater/ # Auto-update, browsers
136
142
  │ ├── input-shortcuts/ # Keyboard shortcuts
143
+ │ ├── autocomplete/ # Enhanced command autocomplete
137
144
  │ └── unipi/ # Umbrella package
138
145
  ├── .unipi/ # Runtime data (specs, plans, worktrees)
139
146
  └── CHANGELOG.md
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -73,6 +73,10 @@ When the user chooses a `new_session` option:
73
73
 
74
74
  The tool result is rendered as `queued compact → ...` or `queued direct → ...`. If automatic delivery fails, ask-user falls back to editor prefill and warns you to press Enter.
75
75
 
76
+ ### History Expansion
77
+
78
+ Completed `ask_user` calls stay readable in chat history. The collapsed result shows the selected answer; press Ctrl+O on the tool result to expand the original question, context, and options that were presented.
79
+
76
80
  ### Keyboard Controls
77
81
 
78
82
  | Mode | Keys |
@@ -621,7 +621,7 @@ export function createRenderCall() {
621
621
  * Create a renderResult function for the ask_user tool.
622
622
  */
623
623
  export function createRenderResult() {
624
- return (result: AgentToolResult<unknown>, _options: unknown, theme: Theme, _context: unknown) => {
624
+ return (result: AgentToolResult<unknown>, options: unknown, theme: Theme, _context: unknown) => {
625
625
  const details = result.details as Record<string, unknown> | undefined;
626
626
  if (!details) {
627
627
  const content = result.content as unknown as Array<Record<string, unknown>> | undefined;
@@ -634,103 +634,110 @@ export function createRenderResult() {
634
634
  return new Text(theme.fg("warning", "No response"), 0, 0);
635
635
  }
636
636
 
637
- switch (response.kind) {
638
- case "cancelled":
639
- return new Text(theme.fg("warning", "Cancelled"), 0, 0);
640
- case "timed_out":
641
- return new Text(theme.fg("warning", "Timed out"), 0, 0);
642
- case "freeform":
643
- return new Text(
644
- theme.fg("success", "✓ ") +
637
+ const renderOptionSummary = (): string[] => {
638
+ const rawOptions = (details as { options?: unknown }).options;
639
+ if (!Array.isArray(rawOptions) || rawOptions.length === 0) return [];
640
+ return rawOptions.map((opt, index) => {
641
+ if (typeof opt === "string") {
642
+ return theme.fg("dim", ` ${index + 1}. `) + theme.fg("text", opt);
643
+ }
644
+ const record = opt as Record<string, unknown>;
645
+ const label = String(record.label ?? record.value ?? `Option ${index + 1}`);
646
+ const value = typeof record.value === "string" && record.value !== label
647
+ ? theme.fg("dim", ` (${record.value})`)
648
+ : "";
649
+ const action = typeof record.action === "string" && record.action !== "select"
650
+ ? theme.fg("dim", ` [${record.action}]`)
651
+ : "";
652
+ const description = typeof record.description === "string" && record.description.trim()
653
+ ? `\n${theme.fg("muted", ` ${record.description}`)}`
654
+ : "";
655
+ return theme.fg("dim", ` ${index + 1}. `) + theme.fg("text", label) + value + action + description;
656
+ });
657
+ };
658
+
659
+ const answerText = (() => {
660
+ switch (response.kind) {
661
+ case "cancelled":
662
+ return theme.fg("warning", "Cancelled");
663
+ case "timed_out":
664
+ return theme.fg("warning", "Timed out");
665
+ case "freeform":
666
+ return theme.fg("success", "✓ ") +
645
667
  theme.fg("muted", "(wrote) ") +
646
- theme.fg("accent", response.text || ""),
647
- 0,
648
- 0,
649
- );
650
- case "selection": {
651
- const selections = response.selections || [];
652
- const display =
653
- selections.length === 1
668
+ theme.fg("accent", response.text || "");
669
+ case "selection": {
670
+ const selections = response.selections || [];
671
+ const display = selections.length === 1 ? selections[0] : selections.join(", ");
672
+ return theme.fg("success", "✓ ") + theme.fg("accent", display);
673
+ }
674
+ case "combined": {
675
+ const selections = response.selections || [];
676
+ const selDisplay = selections.length === 1
654
677
  ? selections[0]
655
678
  : selections.join(", ");
656
- return new Text(
657
- theme.fg("success", "✓ ") + theme.fg("accent", display),
658
- 0,
659
- 0,
660
- );
661
- }
662
- case "combined": {
663
- const selections = response.selections || [];
664
- const selDisplay = selections.length === 1
665
- ? selections[0]
666
- : selections.join(", ");
667
- return new Text(
668
- theme.fg("success", "✓ ") +
679
+ return theme.fg("success", "✓ ") +
669
680
  theme.fg("accent", selDisplay) +
670
681
  theme.fg("muted", " and wrote ") +
671
- theme.fg("accent", response.text || ""),
672
- 0,
673
- 0,
674
- );
675
- }
676
- case "end_turn":
677
- return new Text(
678
- theme.fg("success", "✓ ") + theme.fg("muted", "end turn"),
679
- 0,
680
- 0,
681
- );
682
- case "new_session": {
683
- const prefill = response.prefill || "";
684
- if (response.launchStatus === "editor_prefill") {
685
- const label = response.launchedWith === "compact"
686
- ? "⚠ compact editor prefill → "
687
- : "⚠ direct editor prefill → ";
688
- return new Text(
689
- theme.fg("warning", label) + theme.fg("accent", prefill),
690
- 0,
691
- 0,
692
- );
693
- }
694
- if (response.launchStatus === "failed") {
695
- const label = response.launchedWith === "compact"
696
- ? "handoff failed (compact) → "
697
- : "handoff failed (direct) → ";
698
- return new Text(
699
- theme.fg("error", label) + theme.fg("accent", prefill),
700
- 0,
701
- 0,
702
- );
682
+ theme.fg("accent", response.text || "");
703
683
  }
704
- if (response.launchedWith === "compact") {
705
- return new Text(
706
- theme.fg("success", "✓ queued compact → ") +
707
- theme.fg("accent", prefill),
708
- 0,
709
- 0,
710
- );
711
- }
712
- if (response.launchedWith === "direct") {
713
- return new Text(
714
- theme.fg("success", "✓ queued direct → ") +
715
- theme.fg("accent", prefill),
716
- 0,
717
- 0,
718
- );
719
- }
720
- return new Text(
721
- theme.fg("success", "✓ ") +
684
+ case "end_turn":
685
+ return theme.fg("success", "✓ ") + theme.fg("muted", "end turn");
686
+ case "new_session": {
687
+ const prefill = response.prefill || "";
688
+ if (response.launchStatus === "editor_prefill") {
689
+ const label = response.launchedWith === "compact"
690
+ ? "⚠ compact editor prefill → "
691
+ : "⚠ direct editor prefill → ";
692
+ return theme.fg("warning", label) + theme.fg("accent", prefill);
693
+ }
694
+ if (response.launchStatus === "failed") {
695
+ const label = response.launchedWith === "compact"
696
+ ? "handoff failed (compact) → "
697
+ : "handoff failed (direct) → ";
698
+ return theme.fg("error", label) + theme.fg("accent", prefill);
699
+ }
700
+ if (response.launchedWith === "compact") {
701
+ return theme.fg("success", "✓ queued compact → ") + theme.fg("accent", prefill);
702
+ }
703
+ if (response.launchedWith === "direct") {
704
+ return theme.fg("success", "✓ queued direct → ") + theme.fg("accent", prefill);
705
+ }
706
+ return theme.fg("success", "✓ ") +
722
707
  theme.fg("muted", "new session") +
723
- (prefill ? theme.fg("accent", `: ${prefill}`) : ""),
724
- 0,
725
- 0,
726
- );
708
+ (prefill ? theme.fg("accent", `: ${prefill}`) : "");
709
+ }
710
+ default:
711
+ return theme.fg("text", JSON.stringify(response));
727
712
  }
728
- default:
729
- return new Text(
730
- theme.fg("text", JSON.stringify(response)),
731
- 0,
732
- 0,
733
- );
713
+ })();
714
+
715
+ const expanded = typeof options === "object" && options !== null && "expanded" in options
716
+ ? Boolean((options as { expanded?: boolean }).expanded)
717
+ : false;
718
+ if (!expanded) {
719
+ const question = typeof (details as { question?: unknown }).question === "string"
720
+ ? (details as { question: string }).question
721
+ : "";
722
+ const expandHint = question
723
+ ? theme.fg("dim", " · Ctrl+O question/options")
724
+ : "";
725
+ return new Text(answerText + expandHint, 0, 0);
726
+ }
727
+
728
+ const lines = [answerText];
729
+ const question = (details as { question?: unknown }).question;
730
+ const context = (details as { context?: unknown }).context;
731
+ if (typeof question === "string" && question.trim()) {
732
+ lines.push(theme.fg("muted", "Question: ") + theme.fg("text", question));
734
733
  }
734
+ if (typeof context === "string" && context.trim()) {
735
+ lines.push(theme.fg("muted", "Context: ") + theme.fg("text", context));
736
+ }
737
+ const optionLines = renderOptionSummary();
738
+ if (optionLines.length > 0) {
739
+ lines.push(theme.fg("muted", "Options:"), ...optionLines);
740
+ }
741
+ return new Text(lines.join("\n"), 0, 0);
735
742
  };
736
743
  }
@@ -252,6 +252,13 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
252
252
  action: (opt.action as NormalizedOption["action"]) ?? "select",
253
253
  prefill: opt.prefill,
254
254
  }));
255
+ const detailsBase = {
256
+ question,
257
+ context,
258
+ options: normalizedOptions,
259
+ allowMultiple,
260
+ allowFreeform,
261
+ };
255
262
 
256
263
  // Emit ASK_USER_PROMPT event if notifyOnAsk is enabled
257
264
  if (settings.notifyOnAsk) {
@@ -286,8 +293,7 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
286
293
  },
287
294
  ],
288
295
  details: {
289
- question,
290
- options: normalizedOptions.map((o) => o.label),
296
+ ...detailsBase,
291
297
  response: {
292
298
  kind: "cancelled",
293
299
  } as AskUserResponse,
@@ -348,8 +354,7 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
348
354
  return {
349
355
  content: [{ type: "text", text: "User cancelled the session launch" }],
350
356
  details: {
351
- question,
352
- options: normalizedOptions.map((o) => o.label),
357
+ ...detailsBase,
353
358
  response: {
354
359
  kind: "cancelled",
355
360
  comment: "Session launcher cancelled",
@@ -374,8 +379,7 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
374
379
  return {
375
380
  content: [{ type: "text", text: "Session launch cancelled: no prefill message was provided." }],
376
381
  details: {
377
- question,
378
- options: normalizedOptions.map((o) => o.label),
382
+ ...detailsBase,
379
383
  response: {
380
384
  kind: "cancelled",
381
385
  comment: "Session launcher had no prefill to queue",
@@ -404,8 +408,7 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
404
408
  return {
405
409
  content: [{ type: "text", text: contentText }],
406
410
  details: {
407
- question,
408
- options: normalizedOptions.map((o) => o.label),
411
+ ...detailsBase,
409
412
  response: {
410
413
  ...response,
411
414
  prefill: handoff.prefill ?? prefill,
@@ -421,8 +424,7 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
421
424
  return {
422
425
  content: [{ type: "text", text: contentText }],
423
426
  details: {
424
- question,
425
- options: normalizedOptions.map((o) => o.label),
427
+ ...detailsBase,
426
428
  response,
427
429
  },
428
430
  };
@@ -0,0 +1,36 @@
1
+ # @pi-unipi/command-enchantment
2
+
3
+ Enhanced autocomplete for Unipi commands. It wraps Pi's base autocomplete provider and makes `/unipi:*` suggestions easier to scan with package grouping, stable sorting, short descriptions, and package colors.
4
+
5
+ ## Commands
6
+
7
+ Command Enchantment has no user commands. It improves the editor autocomplete experience automatically when the package is installed.
8
+
9
+ ## What It Does
10
+
11
+ - Groups `/unipi:*` commands by package so workflow, memory, web, footer, and other commands are visually distinct.
12
+ - Sorts matches in predictable tiers: exact Unipi matches first, then other Unipi matches, then system commands.
13
+ - Preserves dynamic argument completions from command providers, including workflow document and worktree suggestions.
14
+ - Ships an audit test that checks registered Unipi commands are represented in the autocomplete registry and have descriptions.
15
+
16
+ ## Development Checks
17
+
18
+ Run the registry audit before releases:
19
+
20
+ ```bash
21
+ npm --workspace packages/autocomplete test -- src/__tests__/command-registry.audit.test.ts
22
+ ```
23
+
24
+ Run all autocomplete tests:
25
+
26
+ ```bash
27
+ npm --workspace packages/autocomplete test
28
+ ```
29
+
30
+ ## Configuration
31
+
32
+ Autocomplete enhancement is enabled by default. The package stores its toggle in the Unipi config and can be disabled by setting `autocompleteEnhanced` to `false`.
33
+
34
+ ## License
35
+
36
+ MIT
@@ -97,6 +97,8 @@ For small tasks that skip the full flow:
97
97
  /unipi:worktree-merge feat/new-feature
98
98
  ```
99
99
 
100
+ Worktree command arguments autocomplete from `.unipi/worktrees`. Suggestions are cached after the first scan in a Pi session so large worktree directories do not slow down repeated `/unipi:worktree-merge` completions.
101
+
100
102
  ## Special Triggers
101
103
 
102
104
  Workflow skills detect installed packages and enhance their behavior automatically. This is the coexists system — each package adds capabilities without requiring configuration.
@@ -10,6 +10,8 @@ import { readFileSync, readdirSync, existsSync, statSync } from "fs";
10
10
  import { join, basename } from "path";
11
11
  import { UNIPI_PREFIX, WORKFLOW_COMMANDS, getToolsForCommand, getSandboxLevel, type SandboxLevel } from "@pi-unipi/core";
12
12
 
13
+ type CompletionItem = { value: string; label: string; description: string };
14
+
13
15
  /** Options for command registration */
14
16
  export interface WorkflowCommandOptions {
15
17
  /** Check if ralph module is detected */
@@ -36,7 +38,7 @@ interface WorkflowCommand {
36
38
  /**
37
39
  * Suggest spec files from .unipi/docs/specs/ for plan command.
38
40
  */
39
- function suggestSpecFiles(prefix: string): { value: string; label: string; description: string }[] {
41
+ function suggestSpecFiles(prefix: string): CompletionItem[] {
40
42
  const specsDir = join(process.cwd(), ".unipi", "docs", "specs");
41
43
  if (!existsSync(specsDir)) return [];
42
44
 
@@ -61,7 +63,7 @@ function suggestSpecFiles(prefix: string): { value: string; label: string; descr
61
63
  /**
62
64
  * Suggest plan files from .unipi/docs/plans/ for work and review-work commands.
63
65
  */
64
- function suggestPlanFiles(prefix: string): { value: string; label: string; description: string }[] {
66
+ function suggestPlanFiles(prefix: string): CompletionItem[] {
65
67
  const plansDir = join(process.cwd(), ".unipi", "docs", "plans");
66
68
  if (!existsSync(plansDir)) return [];
67
69
 
@@ -86,7 +88,7 @@ function suggestPlanFiles(prefix: string): { value: string; label: string; descr
86
88
  /**
87
89
  * Suggest debug files from .unipi/docs/debug/ for fix command.
88
90
  */
89
- function suggestDebugFiles(prefix: string): { value: string; label: string; description: string }[] {
91
+ function suggestDebugFiles(prefix: string): CompletionItem[] {
90
92
  const debugDir = join(process.cwd(), ".unipi", "docs", "debug");
91
93
  if (!existsSync(debugDir)) return [];
92
94
 
@@ -111,7 +113,7 @@ function suggestDebugFiles(prefix: string): { value: string; label: string; desc
111
113
  /**
112
114
  * Suggest chore files from .unipi/docs/chore/ for chore-execute command.
113
115
  */
114
- function suggestChoreFiles(prefix: string): { value: string; label: string; description: string }[] {
116
+ function suggestChoreFiles(prefix: string): CompletionItem[] {
115
117
  const choreDir = join(process.cwd(), ".unipi", "docs", "chore");
116
118
  if (!existsSync(choreDir)) return [];
117
119
 
@@ -133,16 +135,29 @@ function suggestChoreFiles(prefix: string): { value: string; label: string; desc
133
135
  }
134
136
  }
135
137
 
138
+ /** Cached per-cwd worktree suggestions. Worktree autocomplete can be invoked on every
139
+ * keystroke, so the recursive scan is intentionally paid only once per session/cwd.
140
+ */
141
+ let worktreeSuggestionsCache: { cwd: string; items: CompletionItem[] } | null = null;
142
+
136
143
  /**
137
144
  * Suggest existing worktree names for merge/list commands.
138
145
  * Recursively scans for actual git worktrees (directories containing .git files).
139
146
  */
140
- function suggestWorktrees(): { value: string; label: string; description: string }[] {
141
- const worktreesDir = join(process.cwd(), ".unipi", "worktrees");
142
- if (!existsSync(worktreesDir)) return [];
147
+ function suggestWorktrees(): CompletionItem[] {
148
+ const cwd = process.cwd();
149
+ if (worktreeSuggestionsCache?.cwd === cwd) {
150
+ return worktreeSuggestionsCache.items;
151
+ }
152
+
153
+ const worktreesDir = join(cwd, ".unipi", "worktrees");
154
+ if (!existsSync(worktreesDir)) {
155
+ worktreeSuggestionsCache = { cwd, items: [] };
156
+ return worktreeSuggestionsCache.items;
157
+ }
143
158
 
144
159
  try {
145
- const results: { value: string; label: string; description: string }[] = [];
160
+ const results: CompletionItem[] = [];
146
161
 
147
162
  /**
148
163
  * Recursively find worktree directories (those containing a .git file).
@@ -176,9 +191,11 @@ function suggestWorktrees(): { value: string; label: string; description: string
176
191
  }
177
192
 
178
193
  findWorktrees(worktreesDir, "");
179
- return results;
194
+ worktreeSuggestionsCache = { cwd, items: results };
195
+ return worktreeSuggestionsCache.items;
180
196
  } catch {
181
- return [];
197
+ worktreeSuggestionsCache = { cwd, items: [] };
198
+ return worktreeSuggestionsCache.items;
182
199
  }
183
200
  }
184
201
 
@@ -334,7 +351,7 @@ export function registerWorkflowCommands(
334
351
  pi.registerCommand(fullCommand, {
335
352
  description: cmd.description,
336
353
  getArgumentCompletions: (prefix: string) => {
337
- let items: { value: string; label: string; description: string }[] | null = null;
354
+ let items: CompletionItem[] | null = null;
338
355
 
339
356
  // Plan command: suggest spec files
340
357
  if (cmd.name === WORKFLOW_COMMANDS.PLAN) {