@mrclrchtr/supi-extras 1.3.1 → 1.5.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 (21) hide show
  1. package/README.md +60 -25
  2. package/node_modules/@mrclrchtr/supi-core/README.md +52 -41
  3. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +15 -13
  5. package/node_modules/@mrclrchtr/supi-core/src/{config-settings.ts → config/config-settings.ts} +2 -2
  6. package/node_modules/@mrclrchtr/supi-core/src/{context-provider-registry.ts → context/context-provider-registry.ts} +1 -1
  7. package/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -1
  8. package/node_modules/@mrclrchtr/supi-core/src/index.ts +15 -13
  9. package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +40 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +42 -10
  11. package/node_modules/@mrclrchtr/supi-core/src/{settings-registry.ts → settings/settings-registry.ts} +1 -1
  12. package/package.json +2 -2
  13. package/src/index.ts +2 -0
  14. package/src/model-effort-colors-helpers.ts +277 -0
  15. package/src/model-effort-colors.ts +178 -0
  16. package/src/tab-spinner.ts +144 -49
  17. /package/node_modules/@mrclrchtr/supi-core/src/{config.ts → config/config.ts} +0 -0
  18. /package/node_modules/@mrclrchtr/supi-core/src/{context-messages.ts → context/context-messages.ts} +0 -0
  19. /package/node_modules/@mrclrchtr/supi-core/src/{context-tag.ts → context/context-tag.ts} +0 -0
  20. /package/node_modules/@mrclrchtr/supi-core/src/{settings-command.ts → settings/settings-command.ts} +0 -0
  21. /package/node_modules/@mrclrchtr/supi-core/src/{settings-ui.ts → settings/settings-ui.ts} +0 -0
package/README.md CHANGED
@@ -1,44 +1,79 @@
1
1
  # @mrclrchtr/supi-extras
2
2
 
3
- Small fixes for PI the papercuts you didn't know you had, removed.
3
+ Adds a bundle of small quality-of-life features to the [pi coding agent](https://github.com/earendil-works/pi).
4
4
 
5
- Command aliases so you type less. A prompt stash so you never lose a draft. A tab spinner so you know when the agent is working. Each one is tiny. Together they make sessions feel smoother.
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:@mrclrchtr/supi-extras
9
+ ```
10
+
11
+ For local development:
12
+
13
+ ```bash
14
+ pi install ./packages/supi-extras
15
+ ```
16
+
17
+ After editing the source, run `/reload`.
6
18
 
7
19
  ## What you get
8
20
 
9
- ### Fewer keystrokes
21
+ This package mixes a few commands and shortcuts with a few always-on UI tweaks.
10
22
 
11
- `/exit`, `/e`, `/clear` — muscle-memory shortcuts. `$skill-name` expands to `/skill:skill-name` automatically. Less typing, more doing.
23
+ ## Commands
12
24
 
13
- ### Never lose a draft
25
+ - `/exit` exit pi
26
+ - `/e` — alias for `/exit`
27
+ - `/clear` — start a new session (alias for `/new`)
28
+ - `/supi-stash` — browse, restore, copy, delete, or clear saved prompt drafts
14
29
 
15
- `Alt+S` stashes your current prompt. `Alt+C` copies it to clipboard. `/supi-stash` opens a keyboard-driven overlay to browse, restore, or delete saved drafts. Stashes survive restarts.
30
+ ## Shortcuts
16
31
 
17
- ### Know when the agent is working
32
+ - `Alt+S` stash the current editor text
33
+ - `Alt+C` — copy the current editor text to the system clipboard
34
+ - `$skill-name` — input shorthand that expands to `/skill:skill-name`
18
35
 
19
- A braille spinner in the terminal tab title while work is in progress. Glance at the tab if it's spinning, the agent hasn't finished.
36
+ The `$skill-name` helper also adds skill-only autocomplete while the cursor is inside a `$...` token.
20
37
 
21
- ### No hung editors
38
+ ## Prompt stash
22
39
 
23
- Sets `GIT_EDITOR=true` so git never blocks on an interactive editor. Pi runs headless — editor invocations hang. This prevents that.
40
+ Prompt stash stores drafts in `~/.pi/agent/supi/prompt-stash.json` so they survive restarts.
24
41
 
25
- ## Install
42
+ `/supi-stash` opens an overlay with these actions:
26
43
 
27
- ```bash
28
- pi install npm:@mrclrchtr/supi-extras
29
- ```
44
+ - `Enter` — restore the selected draft into the editor
45
+ - `c` — copy the selected draft to the clipboard
46
+ - `d` — delete the selected draft
47
+ - `D` — clear all drafts
48
+ - `Esc` — close the overlay
49
+
50
+ If the stash file cannot be read or written, the feature degrades to in-memory use instead of breaking the extension.
51
+
52
+ ## Passive behavior
53
+
54
+ ### Tab-title spinner
55
+
56
+ While the agent is working, the package animates a spinner in the terminal tab title. When the turn finishes, it shows a done marker. If `ask_user` is active, the spinner pauses so the waiting-for-input title is not overwritten.
57
+
58
+ ### Footer model and effort colors
59
+
60
+ The footer keeps pi's existing information but recolors the active model and reasoning level using theme tokens.
61
+
62
+ ### Headless git safety
63
+
64
+ The package sets:
30
65
 
31
- ## Stash overlay
66
+ - `GIT_EDITOR=true`
67
+ - `GIT_SEQUENCE_EDITOR=true`
32
68
 
33
- `/supi-stash` opens an overlay in your terminal:
69
+ That prevents git subprocesses from hanging while waiting for an interactive editor.
34
70
 
35
- | Key | Action |
36
- |-----|--------|
37
- | `↑↓` | Navigate stashed drafts |
38
- | `Enter` | Restore selected draft to editor |
39
- | `c` | Copy to clipboard |
40
- | `d` | Delete (list refreshes in-place) |
41
- | `D` | Clear all stashes |
42
- | `Esc` | Cancel |
71
+ ## Source
43
72
 
44
- Stashes persist to `~/.pi/agent/supi/prompt-stash.json` across restarts.
73
+ - `src/aliases.ts` command aliases
74
+ - `src/prompt-stash.ts` — prompt stash shortcuts, persistence, and overlay
75
+ - `src/skill-shortcut.ts` — `$skill-name` expansion and autocomplete
76
+ - `src/tab-spinner.ts` — terminal tab-title spinner
77
+ - `src/copy-prompt.ts` and `src/clipboard.ts` — copy-to-clipboard shortcut and helper
78
+ - `src/model-effort-colors.ts` — footer recoloring
79
+ - `src/git-editor.ts` — git editor environment guard
@@ -1,65 +1,78 @@
1
1
  # @mrclrchtr/supi-core
2
2
 
3
- Shared infrastructure for SuPi packages.
3
+ Shared infrastructure for SuPi extensions.
4
+
5
+ This package is mainly for extension authors. It gives you a common config system, settings plumbing, context helpers, registries, and a small extension surface that registers `/supi-settings`.
4
6
 
5
7
  ## Install
6
8
 
7
- Use it as a dependency in another extension package:
9
+ ### As a dependency for another extension
8
10
 
9
11
  ```bash
10
12
  pnpm add @mrclrchtr/supi-core
11
13
  ```
12
14
 
13
- ## Package role
14
-
15
- `@mrclrchtr/supi-core` now has two explicit surfaces:
15
+ ### As a pi package
16
16
 
17
- - `@mrclrchtr/supi-core/api` — shared library helpers for other SuPi packages
18
- - `@mrclrchtr/supi-core/extension` — a minimal pi extension that registers `/supi-settings`
19
-
20
- `pi.extensions` still points at the real file path `./src/extension.ts` inside the package. The `/api` and `/extension` paths are consumer-facing package exports, not manifest aliases.
17
+ ```bash
18
+ pi install npm:@mrclrchtr/supi-core
19
+ ```
21
20
 
22
- ## What it provides
21
+ Installing it as a pi package adds the minimal `/supi-settings` extension surface.
23
22
 
24
- Current exports cover:
23
+ ## Package surfaces
25
24
 
26
- - shared config loading, scoped reads, writes, and key removal
27
- - config-backed settings registration helpers for `/supi-settings`
28
- - the shared settings registry, overlay UI, and `registerSettingsCommand()` helper
29
- - XML `<extension-context>` wrapping plus context-message utilities
30
- - context-provider and debug-event registries reused across SuPi packages
31
- - project root and path helpers reused by packages such as `supi-lsp`
25
+ - `@mrclrchtr/supi-core/api` reusable helpers for other packages and extensions
26
+ - `@mrclrchtr/supi-core/extension` minimal pi extension that registers `/supi-settings`
32
27
 
33
- ## Config system
28
+ ## What you get from the API
34
29
 
35
- Config resolution order:
30
+ ### Config helpers
36
31
 
37
- ```text
38
- defaults <- global <- project
39
- ```
32
+ - `loadSupiConfig()` — merged config with resolution order `defaults <- global <- project`
33
+ - `loadSupiConfigForScope()` load one scope at a time for settings UIs
34
+ - `writeSupiConfig()` — persist values
35
+ - `removeSupiConfigKey()` — remove a key or override
40
36
 
41
37
  Config file locations:
42
38
 
43
39
  - global: `~/.pi/agent/supi/config.json`
44
40
  - project: `.pi/supi/config.json`
45
41
 
46
- Main helpers:
42
+ ### Settings helpers
43
+
44
+ - `registerSettings()` — register an arbitrary settings section
45
+ - `registerConfigSettings()` — register a config-backed settings section with scoped persistence helpers
46
+ - `registerSettingsCommand()` — register `/supi-settings`
47
+ - `openSettingsOverlay()` — open the shared settings UI directly
48
+ - `createInputSubmenu()` — helper for simple text-entry submenus
49
+
50
+ The built-in settings UI supports:
47
51
 
48
- - `loadSupiConfig()` — effective merged config (`defaults <- global <- project`)
49
- - `loadSupiConfigForScope()` raw single-scope config for settings UIs (`defaults <- selected scope`)
50
- - `writeSupiConfig()`
51
- - `removeSupiConfigKey()`
52
- - `registerConfigSettings()`
52
+ - project/global scope toggle
53
+ - grouped extension sections
54
+ - searchable setting lists
53
55
 
54
- ## Context and settings helpers
56
+ ### Context helpers
55
57
 
56
- - `wrapExtensionContext()`
58
+ - `wrapExtensionContext()` — wrap injected text in SuPi's `<extension-context>` tag
57
59
  - `findLastUserMessageIndex()`
58
60
  - `getContextToken()`
61
+ - `getPromptContent()`
59
62
  - `pruneAndReorderContextMessages()`
60
- - `registerSettings()`
61
- - `registerSettingsCommand()`
62
- - `openSettingsOverlay()`
63
+ - `restorePromptContent()`
64
+
65
+ ### Shared registries
66
+
67
+ - context-provider registry for `/supi-context`
68
+ - debug-event registry for producers that want shared debug capture
69
+ - settings registry used by `/supi-settings`
70
+
71
+ ### Project and session helpers
72
+
73
+ - project-root detection and directory walking helpers such as `findProjectRoot()` and `walkProject()`
74
+ - active-branch session helper: `getActiveBranchEntries()`
75
+ - terminal helpers such as `formatTitle()`, `signalWaiting()`, and `signalDone()`
63
76
 
64
77
  ## Example
65
78
 
@@ -80,17 +93,15 @@ registerConfigSettings({
80
93
  });
81
94
 
82
95
  const message = wrapExtensionContext("my-extension", "hello", {
83
- turn: 1,
84
96
  file: "CLAUDE.md",
97
+ turn: 1,
85
98
  });
86
99
  ```
87
100
 
88
- ## Requirements
89
-
90
- - `@earendil-works/pi-coding-agent`
91
- - `@earendil-works/pi-tui`
92
-
93
101
  ## Source
94
102
 
95
- - Library surface: `src/api.ts`
96
- - Extension surface: `src/extension.ts`
103
+ - `src/api.ts` — exported library surface
104
+ - `src/extension.ts` — minimal `/supi-settings` entrypoint
105
+ - `src/config.ts` — shared config loading and writing
106
+ - `src/config-settings.ts` — config-backed settings registration helper
107
+ - `src/settings-ui.ts` — shared settings overlay
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "description": "SuPi core — shared infrastructure for SuPi extensions (XML context tags, config system)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -2,30 +2,30 @@
2
2
  // Provides XML context tag wrapping, unified config system, context-message utilities,
3
3
  // and settings registry for supi-wide TUI settings.
4
4
 
5
- export type { SupiConfigLocation, SupiConfigOptions } from "./config.ts";
5
+ export type { SupiConfigLocation, SupiConfigOptions } from "./config/config.ts";
6
6
  export {
7
7
  loadSupiConfig,
8
8
  loadSupiConfigForScope,
9
9
  removeSupiConfigKey,
10
10
  writeSupiConfig,
11
- } from "./config.ts";
12
- export type { ConfigSettingsHelpers, ConfigSettingsOptions } from "./config-settings.ts";
13
- export { registerConfigSettings } from "./config-settings.ts";
14
- export type { ContextMessageLike } from "./context-messages.ts";
11
+ } from "./config/config.ts";
12
+ export type { ConfigSettingsHelpers, ConfigSettingsOptions } from "./config/config-settings.ts";
13
+ export { registerConfigSettings } from "./config/config-settings.ts";
14
+ export type { ContextMessageLike } from "./context/context-messages.ts";
15
15
  export {
16
16
  findLastUserMessageIndex,
17
17
  getContextToken,
18
18
  getPromptContent,
19
19
  pruneAndReorderContextMessages,
20
20
  restorePromptContent,
21
- } from "./context-messages.ts";
22
- export type { ContextProvider } from "./context-provider-registry.ts";
21
+ } from "./context/context-messages.ts";
22
+ export type { ContextProvider } from "./context/context-provider-registry.ts";
23
23
  export {
24
24
  clearRegisteredContextProviders,
25
25
  getRegisteredContextProviders,
26
26
  registerContextProvider,
27
- } from "./context-provider-registry.ts";
28
- export { wrapExtensionContext } from "./context-tag.ts";
27
+ } from "./context/context-provider-registry.ts";
28
+ export { wrapExtensionContext } from "./context/context-tag.ts";
29
29
  export type {
30
30
  DebugAgentAccess,
31
31
  DebugEvent,
@@ -49,6 +49,7 @@ export {
49
49
  redactDebugData,
50
50
  resetDebugRegistry,
51
51
  } from "./debug-registry.ts";
52
+ export { fileToUri, resolveToolPath, stripToolPathPrefix, uriToFile } from "./path-utils.ts";
52
53
  export type { KnownRootEntry } from "./project-roots.ts";
53
54
  export {
54
55
  buildKnownRootsMap,
@@ -63,15 +64,16 @@ export {
63
64
  sortRootsBySpecificity,
64
65
  walkProject,
65
66
  } from "./project-roots.ts";
67
+ export { createRegistry, createSessionStateRegistry } from "./registry-utils.ts";
66
68
  export { getActiveBranchEntries } from "./session-utils.ts";
67
- export { registerSettingsCommand } from "./settings-command.ts";
68
- export type { SettingsScope, SettingsSection } from "./settings-registry.ts";
69
+ export { registerSettingsCommand } from "./settings/settings-command.ts";
70
+ export type { SettingsScope, SettingsSection } from "./settings/settings-registry.ts";
69
71
  export {
70
72
  clearRegisteredSettings,
71
73
  getRegisteredSettings,
72
74
  registerSettings,
73
- } from "./settings-registry.ts";
74
- export { createInputSubmenu, openSettingsOverlay } from "./settings-ui.ts";
75
+ } from "./settings/settings-registry.ts";
76
+ export { createInputSubmenu, openSettingsOverlay } from "./settings/settings-ui.ts";
75
77
  export type { TitleTarget } from "./terminal.ts";
76
78
  export {
77
79
  DONE_SYMBOL,
@@ -2,9 +2,9 @@
2
2
  // Wraps registerSettings() and centralizes selected-scope loading + scoped persistence.
3
3
 
4
4
  import type { SettingItem } from "@earendil-works/pi-tui";
5
+ import type { SettingsScope } from "../settings/settings-registry.ts";
6
+ import { registerSettings } from "../settings/settings-registry.ts";
5
7
  import { loadSupiConfigForScope, removeSupiConfigKey, writeSupiConfig } from "./config.ts";
6
- import type { SettingsScope } from "./settings-registry.ts";
7
- import { registerSettings } from "./settings-registry.ts";
8
8
 
9
9
  export interface ConfigSettingsHelpers {
10
10
  /** Write a key to the selected scope's config section. */
@@ -3,7 +3,7 @@
3
3
  // Extensions declare context data providers via `registerContextProvider()` during their
4
4
  // factory function. The `/supi-context` command reads them via `getRegisteredContextProviders()`.
5
5
 
6
- import { createRegistry } from "./registry-utils.ts";
6
+ import { createRegistry } from "../registry-utils.ts";
7
7
 
8
8
  export interface ContextProvider {
9
9
  /** Unique identifier — e.g. "rtk" */
@@ -1 +1 @@
1
- export { registerSettingsCommand as default } from "./settings-command.ts";
1
+ export { registerSettingsCommand as default } from "./settings/settings-command.ts";
@@ -2,30 +2,30 @@
2
2
  // Provides XML context tag wrapping, unified config system, context-message utilities,
3
3
  // and settings registry for supi-wide TUI settings.
4
4
 
5
- export type { SupiConfigLocation, SupiConfigOptions } from "./config.ts";
5
+ export type { SupiConfigLocation, SupiConfigOptions } from "./config/config.ts";
6
6
  export {
7
7
  loadSupiConfig,
8
8
  loadSupiConfigForScope,
9
9
  removeSupiConfigKey,
10
10
  writeSupiConfig,
11
- } from "./config.ts";
12
- export type { ConfigSettingsHelpers, ConfigSettingsOptions } from "./config-settings.ts";
13
- export { registerConfigSettings } from "./config-settings.ts";
14
- export type { ContextMessageLike } from "./context-messages.ts";
11
+ } from "./config/config.ts";
12
+ export type { ConfigSettingsHelpers, ConfigSettingsOptions } from "./config/config-settings.ts";
13
+ export { registerConfigSettings } from "./config/config-settings.ts";
14
+ export type { ContextMessageLike } from "./context/context-messages.ts";
15
15
  export {
16
16
  findLastUserMessageIndex,
17
17
  getContextToken,
18
18
  getPromptContent,
19
19
  pruneAndReorderContextMessages,
20
20
  restorePromptContent,
21
- } from "./context-messages.ts";
22
- export type { ContextProvider } from "./context-provider-registry.ts";
21
+ } from "./context/context-messages.ts";
22
+ export type { ContextProvider } from "./context/context-provider-registry.ts";
23
23
  export {
24
24
  clearRegisteredContextProviders,
25
25
  getRegisteredContextProviders,
26
26
  registerContextProvider,
27
- } from "./context-provider-registry.ts";
28
- export { wrapExtensionContext } from "./context-tag.ts";
27
+ } from "./context/context-provider-registry.ts";
28
+ export { wrapExtensionContext } from "./context/context-tag.ts";
29
29
  export type {
30
30
  DebugAgentAccess,
31
31
  DebugEvent,
@@ -49,6 +49,7 @@ export {
49
49
  redactDebugData,
50
50
  resetDebugRegistry,
51
51
  } from "./debug-registry.ts";
52
+ export { fileToUri, resolveToolPath, stripToolPathPrefix, uriToFile } from "./path-utils.ts";
52
53
  export type { KnownRootEntry } from "./project-roots.ts";
53
54
  export {
54
55
  buildKnownRootsMap,
@@ -63,15 +64,16 @@ export {
63
64
  sortRootsBySpecificity,
64
65
  walkProject,
65
66
  } from "./project-roots.ts";
67
+ export { createRegistry, createSessionStateRegistry } from "./registry-utils.ts";
66
68
  export { getActiveBranchEntries } from "./session-utils.ts";
67
- export { registerSettingsCommand } from "./settings-command.ts";
68
- export type { SettingsScope, SettingsSection } from "./settings-registry.ts";
69
+ export { registerSettingsCommand } from "./settings/settings-command.ts";
70
+ export type { SettingsScope, SettingsSection } from "./settings/settings-registry.ts";
69
71
  export {
70
72
  clearRegisteredSettings,
71
73
  getRegisteredSettings,
72
74
  registerSettings,
73
- } from "./settings-registry.ts";
74
- export { createInputSubmenu, openSettingsOverlay } from "./settings-ui.ts";
75
+ } from "./settings/settings-registry.ts";
76
+ export { createInputSubmenu, openSettingsOverlay } from "./settings/settings-ui.ts";
75
77
  export type { TitleTarget } from "./terminal.ts";
76
78
  export {
77
79
  DONE_SYMBOL,
@@ -0,0 +1,40 @@
1
+ import * as path from "node:path";
2
+
3
+ /** Strip pi's optional leading `@` file-path prefix from a tool input. */
4
+ export function stripToolPathPrefix(target: string): string {
5
+ return target.startsWith("@") ? target.slice(1) : target;
6
+ }
7
+
8
+ /**
9
+ * Resolve a tool-style file path from a session cwd.
10
+ *
11
+ * Built-in pi file tools accept a leading `@` prefix in path arguments, so
12
+ * shared SuPi path helpers normalize that prefix before resolving relative
13
+ * paths.
14
+ */
15
+ export function resolveToolPath(cwd: string, target: string): string {
16
+ return path.resolve(cwd, stripToolPathPrefix(target));
17
+ }
18
+
19
+ /** Convert a file path to a file:// URI. */
20
+ export function fileToUri(filePath: string): string {
21
+ const resolved = path.resolve(filePath);
22
+ if (process.platform === "win32") {
23
+ return `file:///${resolved.replace(/\\/g, "/")}`;
24
+ }
25
+ return `file://${resolved}`;
26
+ }
27
+
28
+ /** Convert a file:// URI to a file path. */
29
+ export function uriToFile(uri: string): string {
30
+ if (!uri.startsWith("file://")) return uri;
31
+ let filePath = decodeURIComponent(uri.slice(7));
32
+ if (
33
+ process.platform === "win32" &&
34
+ filePath.startsWith("/") &&
35
+ /^[A-Za-z]:/.test(filePath.slice(1))
36
+ ) {
37
+ filePath = filePath.slice(1);
38
+ }
39
+ return filePath;
40
+ }
@@ -5,8 +5,20 @@
5
5
  // Without this, each symlink path gets its own module copy and its own Map,
6
6
  // so registrations from one instance are invisible to consumers in another.
7
7
 
8
+ import * as path from "node:path";
9
+
8
10
  const SYMBOL_PREFIX = "@mrclrchtr/supi-core/";
9
11
 
12
+ function getGlobalRegistryMap<T>(name: string): Map<string, T> {
13
+ const key = Symbol.for(SYMBOL_PREFIX + name);
14
+ let map = (globalThis as Record<symbol, unknown>)[key] as Map<string, T> | undefined;
15
+ if (!map) {
16
+ map = new Map<string, T>();
17
+ (globalThis as Record<symbol, unknown>)[key] = map;
18
+ }
19
+ return map;
20
+ }
21
+
10
22
  /**
11
23
  * Create a named registry backed by `globalThis` + `Symbol.for`.
12
24
  *
@@ -18,16 +30,7 @@ const SYMBOL_PREFIX = "@mrclrchtr/supi-core/";
18
30
  * @returns An object with `register`, `getAll`, and `clear` functions.
19
31
  */
20
32
  export function createRegistry<T>(name: string) {
21
- const key = Symbol.for(SYMBOL_PREFIX + name);
22
-
23
- const getMap = (): Map<string, T> => {
24
- let map = (globalThis as Record<symbol, unknown>)[key] as Map<string, T> | undefined;
25
- if (!map) {
26
- map = new Map<string, T>();
27
- (globalThis as Record<symbol, unknown>)[key] = map;
28
- }
29
- return map;
30
- };
33
+ const getMap = (): Map<string, T> => getGlobalRegistryMap<T>(name);
31
34
 
32
35
  return {
33
36
  /**
@@ -52,3 +55,32 @@ export function createRegistry<T>(name: string) {
52
55
  },
53
56
  };
54
57
  }
58
+
59
+ /**
60
+ * Create a named session-state registry keyed by normalized cwd.
61
+ *
62
+ * This helper is intended for session-scoped runtime services that should be
63
+ * shared across duplicate jiti module instances while keeping package-specific
64
+ * state unions and convenience wrappers local to the calling package.
65
+ */
66
+ export function createSessionStateRegistry<TState>(name: string) {
67
+ const getMap = (): Map<string, TState> => getGlobalRegistryMap<TState>(name);
68
+ const normalizeCwd = (cwd: string): string => path.resolve(cwd);
69
+
70
+ return {
71
+ /** Get the current state for one session cwd. */
72
+ get: (cwd: string): TState | undefined => {
73
+ return getMap().get(normalizeCwd(cwd));
74
+ },
75
+
76
+ /** Store the current state for one session cwd. */
77
+ set: (cwd: string, state: TState): void => {
78
+ getMap().set(normalizeCwd(cwd), state);
79
+ },
80
+
81
+ /** Clear the current state for one session cwd. */
82
+ clear: (cwd: string): void => {
83
+ getMap().delete(normalizeCwd(cwd));
84
+ },
85
+ };
86
+ }
@@ -4,7 +4,7 @@
4
4
  // factory function. The generic settings UI reads them via `getRegisteredSettings()`.
5
5
 
6
6
  import type { SettingItem } from "@earendil-works/pi-tui";
7
- import { createRegistry } from "./registry-utils.ts";
7
+ import { createRegistry } from "../registry-utils.ts";
8
8
 
9
9
  export type SettingsScope = "project" | "global";
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-extras",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "description": "SuPi extras — command aliases, skill shorthand, tab spinner, /supi-stash prompt stash with TUI overlay, and other small utilities",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "dependencies": {
23
23
  "clipboardy": "^5.3.1",
24
- "@mrclrchtr/supi-core": "1.3.1"
24
+ "@mrclrchtr/supi-core": "1.5.0"
25
25
  },
26
26
  "bundledDependencies": [
27
27
  "@mrclrchtr/supi-core"
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import aliases from "./aliases.ts";
2
2
  import copyPrompt from "./copy-prompt.ts";
3
3
  import gitEditor from "./git-editor.ts";
4
+ import modelEffortColors from "./model-effort-colors.ts";
4
5
  import promptStash from "./prompt-stash.ts";
5
6
  import skillShortcut from "./skill-shortcut.ts";
6
7
  import tabSpinner from "./tab-spinner.ts";
@@ -12,4 +13,5 @@ export default function (pi: Parameters<typeof tabSpinner>[0]) {
12
13
  gitEditor(pi);
13
14
  aliases(pi);
14
15
  skillShortcut(pi);
16
+ modelEffortColors(pi);
15
17
  }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Pure helpers for model-effort-colors extension.
3
+ * Extracted to keep the extension entrypoint under complexity / line-count limits.
4
+ */
5
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
6
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
+
8
+ // ---- re-exports for the extension entrypoint ----
9
+
10
+ export type ModelInfo = {
11
+ id?: string;
12
+ provider?: string;
13
+ reasoning?: boolean;
14
+ contextWindow?: number;
15
+ };
16
+
17
+ export interface UsageEntry {
18
+ type: string;
19
+ message?: {
20
+ role: string;
21
+ usage: {
22
+ input: number;
23
+ output: number;
24
+ cacheRead?: number;
25
+ cacheWrite?: number;
26
+ cost: { total: number };
27
+ };
28
+ };
29
+ }
30
+
31
+ export interface UsageTotals {
32
+ totalInput: number;
33
+ totalOutput: number;
34
+ totalCacheRead: number;
35
+ totalCacheWrite: number;
36
+ totalCost: number;
37
+ }
38
+
39
+ export interface FooterTheme {
40
+ fg(color: ThemeColor, text: string): string;
41
+ }
42
+
43
+ export interface FooterData {
44
+ getGitBranch(): string | null;
45
+ getExtensionStatuses(): ReadonlyMap<string, string>;
46
+ onBranchChange(cb: () => void): () => void;
47
+ }
48
+
49
+ // ---- color mappings ----
50
+
51
+ const PROVIDER_PATTERNS: Array<[RegExp, ThemeColor]> = [
52
+ [/anthropic|claude/, "accent"],
53
+ [/openai|gpt|chatgpt/, "success"],
54
+ [/google|gemini/, "warning"],
55
+ [/mistral|codestral/, "muted"],
56
+ [/xai|grok/, "thinkingXhigh"],
57
+ [/deepseek/, "thinkingHigh"],
58
+ [/meta|llama/, "thinkingMedium"],
59
+ [/ollama|local/, "dim"],
60
+ ];
61
+
62
+ export function providerThemeToken(provider: string | undefined): ThemeColor {
63
+ const haystack = String(provider ?? "").toLowerCase();
64
+ for (const [re, color] of PROVIDER_PATTERNS) {
65
+ if (re.test(haystack)) return color;
66
+ }
67
+ return "dim";
68
+ }
69
+
70
+ export function thinkingThemeToken(level: string): ThemeColor {
71
+ switch (level.toLowerCase()) {
72
+ case "off":
73
+ return "thinkingOff";
74
+ case "minimal":
75
+ return "thinkingMinimal";
76
+ case "low":
77
+ return "thinkingLow";
78
+ case "medium":
79
+ return "thinkingMedium";
80
+ case "high":
81
+ return "thinkingHigh";
82
+ case "xhigh":
83
+ return "thinkingXhigh";
84
+ default:
85
+ return "dim";
86
+ }
87
+ }
88
+
89
+ // ---- formatting helpers ----
90
+
91
+ export function formatTokens(count: number): string {
92
+ if (count < 1000) return count.toString();
93
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
94
+ if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
95
+ if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
96
+ return `${Math.round(count / 1_000_000)}M`;
97
+ }
98
+
99
+ export function sanitizeStatusText(text: string): string {
100
+ return text
101
+ .replace(/[\r\n\t]/g, " ")
102
+ .replace(/ +/g, " ")
103
+ .trim();
104
+ }
105
+
106
+ // ---- usage aggregation ----
107
+
108
+ /** Aggregate token/cost usage across all assistant messages. */
109
+ export function gatherUsage(entries: ReadonlyArray<UsageEntry>): UsageTotals {
110
+ let totalInput = 0;
111
+ let totalOutput = 0;
112
+ let totalCacheRead = 0;
113
+ let totalCacheWrite = 0;
114
+ let totalCost = 0;
115
+
116
+ for (const entry of entries) {
117
+ if (entry.type === "message" && entry.message?.role === "assistant") {
118
+ const u = entry.message.usage;
119
+ totalInput += u.input;
120
+ totalOutput += u.output;
121
+ totalCacheRead += u.cacheRead ?? 0;
122
+ totalCacheWrite += u.cacheWrite ?? 0;
123
+ totalCost += u.cost.total;
124
+ }
125
+ }
126
+
127
+ return { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost };
128
+ }
129
+
130
+ // ---- stats builder ----
131
+
132
+ /** Build the left-side stats string. */
133
+ export function buildStatsLeft(params: {
134
+ contextWindow: number;
135
+ percent: number | null;
136
+ usage: UsageTotals;
137
+ useSubscription: boolean;
138
+ }): { text: string; contextPercentValue: number } {
139
+ const { contextWindow, percent, usage, useSubscription } = params;
140
+ const { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost } = usage;
141
+
142
+ const contextPercentValue = percent ?? 0;
143
+ const contextPercent = percent != null ? percent.toFixed(1) : "?";
144
+
145
+ const parts: string[] = [];
146
+ if (totalInput) parts.push(`↑${formatTokens(totalInput)}`);
147
+ if (totalOutput) parts.push(`↓${formatTokens(totalOutput)}`);
148
+ if (totalCacheRead) parts.push(`R${formatTokens(totalCacheRead)}`);
149
+ if (totalCacheWrite) parts.push(`W${formatTokens(totalCacheWrite)}`);
150
+
151
+ if (totalCost || useSubscription) {
152
+ parts.push(`$${totalCost.toFixed(3)}${useSubscription ? " (sub)" : ""}`);
153
+ }
154
+
155
+ const ctxDisplay =
156
+ contextPercent === "?"
157
+ ? `?/${formatTokens(contextWindow)}`
158
+ : `${contextPercent}%/${formatTokens(contextWindow)}`;
159
+ parts.push(ctxDisplay);
160
+
161
+ return { text: parts.join(" "), contextPercentValue };
162
+ }
163
+
164
+ // ---- context-percent coloring ----
165
+
166
+ /** Color the context-% portion based on thresholds. */
167
+ export function colorContextPercent(statsLeft: string, pctVal: number, theme: FooterTheme): string {
168
+ if (pctVal > 90) {
169
+ return statsLeft.replace(/\d+\.?\d*%/, (m) => theme.fg("error", m));
170
+ }
171
+ if (pctVal > 70) {
172
+ return statsLeft.replace(/\d+\.?\d*%/, (m) => theme.fg("warning", m));
173
+ }
174
+ return statsLeft;
175
+ }
176
+
177
+ // ---- right-side builders ----
178
+
179
+ /** Build the styled right portion (model name + effort level). */
180
+ export function styleRightSide(params: {
181
+ model: ModelInfo | undefined;
182
+ thinkingLevel: string;
183
+ theme: FooterTheme;
184
+ statsLeftWidth: number;
185
+ availableWidth: number;
186
+ }): { plain: string; styled: string } {
187
+ const { model, thinkingLevel, theme, statsLeftWidth, availableWidth } = params;
188
+
189
+ const modelName = model?.id ?? "no-model";
190
+ const effortLabel = thinkingLevel === "off" ? "thinking off" : thinkingLevel.toLowerCase();
191
+
192
+ let plain = modelName;
193
+ if (model?.reasoning) {
194
+ plain = `${modelName} • ${effortLabel}`;
195
+ }
196
+ if (model?.provider) {
197
+ const withProvider = `(${model.provider}) ${plain}`;
198
+ if (statsLeftWidth + 2 + visibleWidth(withProvider) <= availableWidth) {
199
+ plain = withProvider;
200
+ }
201
+ }
202
+
203
+ const providerToken = providerThemeToken(model?.provider);
204
+ const thinkingToken = thinkingThemeToken(thinkingLevel);
205
+ const providerPrefix = model?.provider ? `(${model.provider}) ` : "";
206
+
207
+ let styled = "";
208
+ if (providerPrefix) styled += theme.fg("dim", providerPrefix);
209
+ styled += theme.fg(providerToken, modelName);
210
+
211
+ if (model?.reasoning) {
212
+ styled += theme.fg("dim", " • ");
213
+ styled += theme.fg(thinkingToken, effortLabel);
214
+ }
215
+
216
+ return { plain, styled };
217
+ }
218
+
219
+ /** Apply right-justified padding between stats and right side. */
220
+ export function layoutRightSide(params: {
221
+ plain: string;
222
+ styled: string;
223
+ statsLeftWidth: number;
224
+ availableWidth: number;
225
+ }): { styled: string; padding: string } {
226
+ const { plain, styled, statsLeftWidth, availableWidth } = params;
227
+ const rightWidth = visibleWidth(plain);
228
+ const minPadding = 2;
229
+
230
+ if (statsLeftWidth + minPadding + rightWidth <= availableWidth) {
231
+ return { styled, padding: " ".repeat(availableWidth - statsLeftWidth - rightWidth) };
232
+ }
233
+
234
+ const avail = availableWidth - statsLeftWidth - minPadding;
235
+ if (avail <= 0) {
236
+ return { styled: "", padding: "" };
237
+ }
238
+
239
+ const finalStyled = truncateToWidth(styled, avail + (styled.length - plain.length), "");
240
+ const finalPlain = truncateToWidth(plain, avail, "");
241
+ const padding = " ".repeat(
242
+ Math.max(0, availableWidth - statsLeftWidth - visibleWidth(finalPlain)),
243
+ );
244
+ return { styled: finalStyled, padding };
245
+ }
246
+
247
+ // ---- path line ----
248
+
249
+ /** Build the working-directory line for the footer. */
250
+ export function buildPwdLine(params: {
251
+ cwd: string;
252
+ gitBranch: string | null;
253
+ sessionName: string | undefined;
254
+ }): string {
255
+ const home = process.env.HOME || process.env.USERPROFILE;
256
+ let pwd = params.cwd;
257
+ if (home && pwd.startsWith(home)) {
258
+ pwd = `~${pwd.slice(home.length)}`;
259
+ }
260
+ if (params.gitBranch) pwd = `${pwd} (${params.gitBranch})`;
261
+ if (params.sessionName) pwd = `${pwd} • ${params.sessionName}`;
262
+ return pwd;
263
+ }
264
+
265
+ // ---- thinking level ----
266
+
267
+ export function latestThinkingLevel(
268
+ entries: ReadonlyArray<{ type: string; thinkingLevel?: string }>,
269
+ ): string {
270
+ let level = "off";
271
+ for (const e of entries) {
272
+ if (e.type === "thinking_level_change" && e.thinkingLevel) {
273
+ level = e.thinkingLevel;
274
+ }
275
+ }
276
+ return level;
277
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Model/effort footer coloring for pi.
3
+ *
4
+ * Colors the model name in the footer by semantically mapping the provider
5
+ * to PI theme tokens, and colors the thinking/effort level using PI's
6
+ * built-in thinking-level theme tokens. No hardcoded hex colors, no
7
+ * animations.
8
+ */
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
+ import {
12
+ buildPwdLine,
13
+ buildStatsLeft,
14
+ colorContextPercent,
15
+ type FooterData,
16
+ type FooterTheme,
17
+ gatherUsage,
18
+ latestThinkingLevel,
19
+ layoutRightSide,
20
+ type ModelInfo,
21
+ sanitizeStatusText,
22
+ styleRightSide,
23
+ type UsageEntry,
24
+ } from "./model-effort-colors-helpers.ts";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Extension
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export default function modelEffortColors(pi: ExtensionAPI) {
31
+ let currentModel: unknown;
32
+ let requestRender: (() => void) | undefined;
33
+
34
+ pi.on("session_start", (_event, ctx) => {
35
+ currentModel = ctx.model;
36
+ installFooter(ctx);
37
+ });
38
+
39
+ pi.on("model_select", (event, _ctx) => {
40
+ currentModel = event.model;
41
+ requestRender?.();
42
+ });
43
+
44
+ pi.on("thinking_level_select", (_event, _ctx) => {
45
+ requestRender?.();
46
+ });
47
+
48
+ pi.on("session_shutdown", () => {
49
+ currentModel = undefined;
50
+ requestRender = undefined;
51
+ });
52
+
53
+ // ---- footer installation ----
54
+
55
+ // biome-ignore lint/suspicious/noExplicitAny: ctx type from pi session_start handler is complex
56
+ function installFooter(ctx: any) {
57
+ ctx.ui.setFooter(
58
+ (tui: { requestRender(): void }, theme: FooterTheme, footerData: FooterData) => {
59
+ requestRender = () => tui.requestRender();
60
+ const branchUnsub = footerData.onBranchChange(() => tui.requestRender());
61
+
62
+ return {
63
+ dispose() {
64
+ branchUnsub();
65
+ requestRender = undefined;
66
+ },
67
+ invalidate() {},
68
+ render(width: number): string[] {
69
+ const model = (currentModel ?? ctx.model) as ModelInfo | undefined;
70
+
71
+ // Thinking level
72
+ const thinkingLevel = resolveThinkingLevel(
73
+ model,
74
+ (pi as { getThinkingLevel?: () => string }).getThinkingLevel,
75
+ ctx.sessionManager.getEntries(),
76
+ );
77
+
78
+ // Usage
79
+ const usage = gatherUsage(ctx.sessionManager.getEntries() as ReadonlyArray<UsageEntry>);
80
+
81
+ // Context window
82
+ const contextUsage = ctx.getContextUsage() as
83
+ | { percent?: number | null; contextWindow?: number }
84
+ | undefined;
85
+ const contextWindow = contextUsage?.contextWindow ?? model?.contextWindow ?? 0;
86
+ const useSubscription = model
87
+ ? ctx.modelRegistry.isUsingOAuth(
88
+ // biome-ignore lint/suspicious/noExplicitAny: pi Model<Api> type is generic
89
+ model as any,
90
+ )
91
+ : false;
92
+
93
+ // Stats
94
+ const rawStats = buildStatsLeft({
95
+ contextWindow,
96
+ percent: contextUsage?.percent ?? null,
97
+ usage,
98
+ useSubscription,
99
+ });
100
+
101
+ let statsLeft = rawStats.text;
102
+ let statsLeftWidth = visibleWidth(statsLeft);
103
+ if (statsLeftWidth > width) {
104
+ statsLeft = truncateToWidth(statsLeft, width, "...");
105
+ statsLeftWidth = visibleWidth(statsLeft);
106
+ }
107
+ statsLeft = colorContextPercent(statsLeft, rawStats.contextPercentValue, theme);
108
+
109
+ // Right side
110
+ const right = styleRightSide({
111
+ model,
112
+ thinkingLevel,
113
+ theme,
114
+ statsLeftWidth,
115
+ availableWidth: width,
116
+ });
117
+ const laidOut = layoutRightSide({
118
+ plain: right.plain,
119
+ styled: right.styled,
120
+ statsLeftWidth,
121
+ availableWidth: width,
122
+ });
123
+
124
+ // Path line + assembly
125
+ const pwdLine = truncateToWidth(
126
+ theme.fg(
127
+ "dim",
128
+ buildPwdLine({
129
+ cwd: ctx.sessionManager.getCwd(),
130
+ gitBranch: footerData.getGitBranch(),
131
+ sessionName: ctx.sessionManager.getSessionName(),
132
+ }),
133
+ ),
134
+ width,
135
+ theme.fg("dim", "..."),
136
+ );
137
+
138
+ const lines = [
139
+ pwdLine,
140
+ theme.fg("dim", statsLeft) + theme.fg("dim", laidOut.padding) + laidOut.styled,
141
+ ];
142
+
143
+ // Extension statuses
144
+ buildStatusLine(lines, footerData, width, theme);
145
+
146
+ return lines;
147
+ },
148
+ };
149
+ },
150
+ );
151
+ }
152
+ }
153
+
154
+ /** Resolve the current thinking level. */
155
+ function resolveThinkingLevel(
156
+ model: ModelInfo | undefined,
157
+ getThinkingLevel: (() => string) | undefined,
158
+ entries: ReadonlyArray<{ type: string; thinkingLevel?: string }>,
159
+ ): string {
160
+ if (!model?.reasoning) return "off";
161
+ return getThinkingLevel?.() ?? latestThinkingLevel(entries);
162
+ }
163
+ function buildStatusLine(
164
+ lines: string[],
165
+ footerData: FooterData,
166
+ width: number,
167
+ theme: FooterTheme,
168
+ ): void {
169
+ const statuses = footerData.getExtensionStatuses();
170
+ if (statuses.size === 0) return;
171
+
172
+ const entries = Array.from(statuses.entries()) as Array<[string, string]>;
173
+ const statusLine = entries
174
+ .sort((a, b) => a[0].localeCompare(b[0]))
175
+ .map(([, text]) => sanitizeStatusText(text))
176
+ .join(" ");
177
+ lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
178
+ }
@@ -13,113 +13,208 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
13
13
  import { formatTitle, signalDone } from "@mrclrchtr/supi-core/api";
14
14
 
15
15
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
16
+ const AGENT_END_SETTLE_MS = 200;
16
17
 
18
+ // biome-ignore lint/complexity/noExcessiveLinesPerFunction: spinner state and event wiring are intentionally colocated
17
19
  export default function tabSpinner(pi: ExtensionAPI) {
18
20
  let timer: ReturnType<typeof setInterval> | null = null;
21
+ let pendingAgentEndTimer: ReturnType<typeof setTimeout> | null = null;
19
22
  let frame = 0;
20
23
  let activeCount = 0;
21
24
  let hasActiveAgent = false;
25
+ let pendingAgentEnd = false;
22
26
  let askUserActive = 0;
23
27
  let currentCtx: ExtensionContext | undefined;
28
+ let cachedSessionName: string | undefined;
29
+ let cachedCwd: string | undefined;
30
+ const unregisterBusHandlers: Array<() => void> = [];
24
31
 
25
- /** Build the current base title from session name and cwd. */
32
+ /** Build the current base title from cached cwd plus the latest safe session name lookup. */
26
33
  function title() {
27
- return formatTitle(pi.getSessionName(), currentCtx?.cwd);
34
+ return formatTitle(getSessionNameSafe(), cachedCwd);
28
35
  }
29
36
 
30
- /** Restore the base title immediately. */
31
- function stop() {
37
+ function clearPendingAgentEnd() {
38
+ pendingAgentEnd = false;
39
+ if (pendingAgentEndTimer) {
40
+ clearTimeout(pendingAgentEndTimer);
41
+ pendingAgentEndTimer = null;
42
+ }
43
+ }
44
+
45
+ function clearSpinnerTimer() {
32
46
  if (timer) {
33
47
  clearInterval(timer);
34
48
  timer = null;
35
49
  }
36
- frame = 0;
37
- if (currentCtx) {
38
- currentCtx.ui.setTitle(title());
50
+ }
51
+
52
+ function handleStaleContext() {
53
+ clearPendingAgentEnd();
54
+ clearSpinnerTimer();
55
+ activeCount = 0;
56
+ hasActiveAgent = false;
57
+ currentCtx = undefined;
58
+ }
59
+
60
+ function rememberContext(ctx: ExtensionContext) {
61
+ currentCtx = ctx;
62
+ cachedCwd = ctx.cwd;
63
+ cachedSessionName = getSessionNameSafe();
64
+ }
65
+
66
+ function getSessionNameSafe(): string | undefined {
67
+ try {
68
+ const next = pi.getSessionName();
69
+ if (next !== undefined) {
70
+ cachedSessionName = next;
71
+ }
72
+ return cachedSessionName;
73
+ } catch {
74
+ handleStaleContext();
75
+ return cachedSessionName;
39
76
  }
40
77
  }
41
78
 
79
+ function safelySetTitle(nextTitle: string) {
80
+ if (!currentCtx) return;
81
+ try {
82
+ currentCtx.ui.setTitle(nextTitle);
83
+ } catch {
84
+ handleStaleContext();
85
+ }
86
+ }
87
+
88
+ /** Restore the base title immediately. */
89
+ function stop() {
90
+ clearPendingAgentEnd();
91
+ clearSpinnerTimer();
92
+ frame = 0;
93
+ const baseTitle = title();
94
+ safelySetTitle(baseTitle);
95
+ }
96
+
42
97
  /** Show the ✓ done symbol in the title and play the terminal bell. */
43
98
  function showDone() {
44
- if (timer) {
45
- clearInterval(timer);
46
- timer = null;
47
- }
99
+ clearPendingAgentEnd();
100
+ clearSpinnerTimer();
48
101
  frame = 0;
49
- if (currentCtx) {
50
- signalDone(currentCtx, title());
102
+ const baseTitle = title();
103
+ if (!currentCtx) return;
104
+ try {
105
+ signalDone(currentCtx, baseTitle);
106
+ } catch {
107
+ handleStaleContext();
51
108
  }
52
109
  }
53
110
 
54
111
  /** Start the spinner interval. Overwrites any ✓ shown. */
55
112
  function start() {
56
- if (timer) return;
57
- if (!currentCtx) return;
113
+ if (timer || !currentCtx) return;
58
114
  timer = setInterval(() => {
59
115
  const icon = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
60
- currentCtx?.ui.setTitle(`${icon} ${title()}`);
116
+ const baseTitle = title();
117
+ safelySetTitle(`${icon} ${baseTitle}`);
61
118
  frame++;
62
119
  }, 80);
63
120
  }
64
121
 
65
122
  function increment(ctx: ExtensionContext) {
66
- currentCtx = ctx;
123
+ clearPendingAgentEnd();
124
+ rememberContext(ctx);
67
125
  activeCount++;
68
- if (activeCount === 1) start();
126
+ if (activeCount === 1 && askUserActive === 0) start();
127
+ }
128
+
129
+ function resumePendingAgent(ctx: ExtensionContext) {
130
+ if (!pendingAgentEnd) return;
131
+ clearPendingAgentEnd();
132
+ rememberContext(ctx);
133
+ hasActiveAgent = true;
134
+ if (askUserActive === 0) start();
69
135
  }
70
136
 
71
137
  /** Decrement count for supi:working tasks — restores title when idle. */
72
138
  function decrement() {
73
- const floor = hasActiveAgent ? 1 : 0;
139
+ const floor = hasActiveAgent || pendingAgentEnd ? 1 : 0;
74
140
  activeCount = Math.max(floor, activeCount - 1);
75
141
  if (activeCount === 0) stop();
76
142
  }
77
143
 
78
- /** Decrement count for agent turns — shows ✓ when idle. */
79
- function agentEnded() {
144
+ function finalizeAgentEnd() {
145
+ clearPendingAgentEnd();
80
146
  activeCount = Math.max(0, activeCount - 1);
81
- if (activeCount === 0) showDone();
147
+ if (activeCount === 0) {
148
+ showDone();
149
+ return;
150
+ }
151
+ if (askUserActive === 0) start();
82
152
  }
83
153
 
154
+ /** Defer the done state briefly so immediate retries do not flash ✓. */
155
+ function agentEnded() {
156
+ clearPendingAgentEnd();
157
+ pendingAgentEnd = true;
158
+ pendingAgentEndTimer = setTimeout(() => {
159
+ finalizeAgentEnd();
160
+ }, AGENT_END_SETTLE_MS);
161
+ pendingAgentEndTimer.unref?.();
162
+ }
163
+
164
+ function unregisterEvents() {
165
+ for (const unregister of unregisterBusHandlers.splice(0)) {
166
+ unregister();
167
+ }
168
+ }
169
+
170
+ pi.on("session_start", async (_event, ctx) => {
171
+ rememberContext(ctx);
172
+ });
173
+
84
174
  pi.on("agent_start", async (_event, ctx) => {
175
+ if (pendingAgentEnd) {
176
+ resumePendingAgent(ctx);
177
+ return;
178
+ }
85
179
  hasActiveAgent = true;
86
180
  increment(ctx);
87
181
  });
88
182
 
89
- pi.on("agent_end", async (_event, _ctx) => {
183
+ pi.on("turn_start", async (_event, ctx) => resumePendingAgent(ctx));
184
+
185
+ pi.on("agent_end", async (event) => {
186
+ const retryAwareEvent = event as { willRetry?: boolean };
187
+ if (retryAwareEvent.willRetry) return;
90
188
  hasActiveAgent = false;
91
189
  agentEnded();
92
190
  });
93
191
 
94
192
  pi.on("session_shutdown", async (_event, ctx) => {
193
+ unregisterEvents();
95
194
  activeCount = 0;
96
- currentCtx = ctx;
195
+ rememberContext(ctx);
97
196
  stop();
197
+ currentCtx = undefined;
98
198
  });
99
199
 
100
- pi.events.on("supi:working:start", () => {
101
- if (currentCtx) increment(currentCtx);
102
- });
103
-
104
- pi.events.on("supi:working:end", () => {
105
- decrement();
106
- });
107
-
108
- pi.events.on("supi:ask-user:start", () => {
109
- askUserActive++;
110
- // Pause the spinner so ask_user's attention title (set via signalWaiting)
111
- // is visible to the user instead of being overwritten on the next tick.
112
- if (timer) {
113
- clearInterval(timer);
114
- timer = null;
115
- }
116
- });
117
-
118
- pi.events.on("supi:ask-user:end", () => {
119
- askUserActive = Math.max(0, askUserActive - 1);
120
- if (askUserActive === 0 && activeCount > 0) {
121
- // Resume the spinner if the agent (or background work) is still running.
122
- start();
123
- }
124
- });
200
+ unregisterBusHandlers.push(
201
+ pi.events.on("supi:working:start", () => {
202
+ if (currentCtx) increment(currentCtx);
203
+ }),
204
+ );
205
+ unregisterBusHandlers.push(pi.events.on("supi:working:end", () => decrement()));
206
+
207
+ unregisterBusHandlers.push(
208
+ pi.events.on("supi:ask-user:start", () => {
209
+ askUserActive++;
210
+ clearSpinnerTimer();
211
+ }),
212
+ );
213
+
214
+ unregisterBusHandlers.push(
215
+ pi.events.on("supi:ask-user:end", () => {
216
+ askUserActive = Math.max(0, askUserActive - 1);
217
+ if (askUserActive === 0 && activeCount > 0) start();
218
+ }),
219
+ );
125
220
  }