@vanillagreen/pi-qol 1.2.0 → 1.4.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.
- package/README.md +114 -70
- package/extensions/qol/agent-end.ts +48 -0
- package/extensions/qol/agent-statusline.ts +52 -0
- package/extensions/qol/ansi.ts +115 -0
- package/extensions/qol/bridges.ts +78 -0
- package/extensions/qol/compaction.ts +264 -0
- package/extensions/qol/constants.ts +107 -0
- package/extensions/qol/context-usage.ts +492 -0
- package/extensions/qol/editor.ts +183 -0
- package/extensions/qol/handoff.ts +109 -0
- package/extensions/qol/images.ts +123 -0
- package/extensions/qol/notifications.ts +269 -0
- package/extensions/qol/pending-queue.ts +79 -0
- package/extensions/qol/permission-gate.ts +139 -0
- package/extensions/qol/session-rename.ts +359 -0
- package/extensions/qol/session-search/cache.ts +273 -0
- package/extensions/qol/session-search/component.ts +937 -0
- package/extensions/qol/session-search/context.ts +152 -0
- package/extensions/qol/session-search/index.ts +171 -0
- package/extensions/qol/session-search/search.ts +288 -0
- package/extensions/qol/session-search/types.ts +96 -0
- package/extensions/qol/settings.ts +74 -0
- package/extensions/qol/status-message.ts +41 -0
- package/extensions/qol/statusline.ts +219 -0
- package/extensions/qol/thinking-timer.ts +97 -0
- package/extensions/qol/util.ts +7 -0
- package/extensions/qol.ts +191 -4437
- package/package.json +15 -1
package/README.md
CHANGED
|
@@ -5,7 +5,21 @@
|
|
|
5
5
|

|
|
6
6
|

|
|
7
7
|
|
|
8
|
-
Quality-of-life extension for Pi.
|
|
8
|
+
Quality-of-life extension for Pi: compact statusline, multiline input, session naming and search, notifications, and a permission gate.
|
|
9
|
+
|
|
10
|
+
## Highlights
|
|
11
|
+
|
|
12
|
+
- Compact statusline with repo, branch, model, thinking level, and context percent.
|
|
13
|
+
- Shift+Enter inserts a newline. Ctrl+J fallback for terminals that can't distinguish it.
|
|
14
|
+
- Sessions auto-name from your first prompt. `/rename` overrides anytime.
|
|
15
|
+
- `/search` browses previous sessions with snippet previews. F2 opens it instantly.
|
|
16
|
+
- `/context` shows a Claude-style context-window breakdown.
|
|
17
|
+
- `/handoff <goal>` drafts a focused prompt for a new session.
|
|
18
|
+
- Permission gate prompts before risky `bash` commands. Default match: `rm -Rf`.
|
|
19
|
+
- Notifications for ready, questions, blocked states, and task completion.
|
|
20
|
+
- Thinking timer next to collapsed `Thinking...` labels.
|
|
21
|
+
- Caveman badge and Alt+C mode cycling when `pi-caveman` is loaded.
|
|
22
|
+
- Subagent-name badge in `pi-agents-tmux` child panes.
|
|
9
23
|
|
|
10
24
|
## Install
|
|
11
25
|
|
|
@@ -24,107 +38,137 @@ vstack add vanillagreencom/vstack --pi-extension pi-qol --harness pi -y
|
|
|
24
38
|
|
|
25
39
|
Restart Pi after installation.
|
|
26
40
|
|
|
27
|
-
## What it provides
|
|
28
|
-
|
|
29
|
-
- Compact statusline and `π` prompt editor: shows repo/project, branch/dirty state, model, colorized thinking level, optional Caveman badge, context window size, remaining context percent, and (inside `pi-agents-tmux` child panes) an ANSI-background subagent-name badge while replacing Pi's default footer/editor chrome.
|
|
30
|
-
- Reliable multiline input: `Shift+Enter` / `Shift+Return` inserts a newline when the terminal reports it distinctly; `ctrl+j` is the default fallback newline key. `Alt+Enter` is reserved for Pi follow-up messages. QOL can also style Pi's built-in steering/follow-up pending queue preview with an ANSI green left bar while leaving the restore hint muted. When `pi-caveman` is loaded, `Alt+C` cycles Caveman modes.
|
|
31
|
-
- Compact image placeholders: existing pasted image paths can collapse to `[Image #N]` aliases and are attached on submit.
|
|
32
|
-
- Session naming: `/rename [name]` sets or shows the friendly session name; automatic first-prompt naming is enabled by default.
|
|
33
|
-
- Context usage: `/context` prints an inline Claude-style context-window visualization with estimated Pi/model category breakdowns.
|
|
34
|
-
- Previous-session search: `/search` and optional `F2` overlay search prior sessions, preview snippets, resume, inject summarized context, or start a new session with summarized context.
|
|
35
|
-
- Handoff: `/handoff <goal>` drafts a focused prompt for a new session, preserving the latest compaction summary plus retained branch entries.
|
|
36
|
-
- Optional permission gate: when enabled, prompts before configured `bash` tool command fragments run; default match is `rm -Rf`.
|
|
37
|
-
- Notifications: terminal/tmux/native notifications for ready-for-input, questions, direction needed, task completion, and critical/blocked states.
|
|
38
|
-
- Optional custom compaction and idle compaction; disabled by default so Pi's compaction behavior is unchanged until enabled.
|
|
39
|
-
- Thinking timer next to collapsed `Thinking...` labels; enabled by default and falls back to Pi defaults if internals change.
|
|
40
|
-
|
|
41
41
|
## Commands
|
|
42
42
|
|
|
43
43
|
| Command | Action |
|
|
44
44
|
| --- | --- |
|
|
45
|
-
| `/qol` | Open
|
|
45
|
+
| `/qol` | Open settings (or print status if extension-manager isn't installed). |
|
|
46
46
|
| `/qol notify-test` | Send a test notification. |
|
|
47
|
-
| `/
|
|
48
|
-
| `/qol:rename
|
|
49
|
-
| `/rename
|
|
50
|
-
| `/context` | Show
|
|
51
|
-
| `/search [query]` | Open previous-session search
|
|
47
|
+
| `/rename [name]` | Set or show the current session's name. |
|
|
48
|
+
| `/qol:rename` | Regenerate the session name from the first prompt. |
|
|
49
|
+
| `/qol:rename:full` | Regenerate from the full conversation. |
|
|
50
|
+
| `/context` | Show context-window usage with category breakdown. |
|
|
51
|
+
| `/search [query]` | Open previous-session search. |
|
|
52
52
|
| `/search:refresh` | Refresh the session search cache. |
|
|
53
|
-
| `/handoff <goal>` | Draft a
|
|
53
|
+
| `/handoff <goal>` | Draft a handoff prompt for a new session. |
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
Arguments support autocomplete.
|
|
56
56
|
|
|
57
57
|
## Settings
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
All settings live in the extension manager under **QOL**. Names below match the labels shown there.
|
|
60
60
|
|
|
61
|
-
### Statusline
|
|
61
|
+
### Statusline
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
63
|
+
| Setting | What it does |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| Enable QOL editor helpers | Master toggle for QOL statusline, commands, notifications, search, compaction, and editor helpers. |
|
|
66
|
+
| Replace built-in footer | Hide Pi's default footer while the QOL statusline is active. |
|
|
67
|
+
| Use π prompt editor | Use the compact prompt editor. |
|
|
68
|
+
| Show session name title | Show the session name above the prompt and in the tmux pane title. |
|
|
69
|
+
| Sync session name to tmux window name | Rename the tmux window to `π <session>`. |
|
|
70
|
+
| Input bottom padding | Blank lines below the prompt. |
|
|
71
|
+
| Show dirty marker | Append `*` to the branch when the worktree is dirty. |
|
|
71
72
|
|
|
72
73
|
### Input
|
|
73
74
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
| Setting | What it does |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| Shift+Enter inserts newline | Insert a newline instead of submitting on Shift+Enter. |
|
|
78
|
+
| Fallback newline key | Alternate newline key for terminals that can't send Shift+Enter. |
|
|
79
|
+
| Style pending queue preview | Highlight Pi's pending-queue preview with a green left bar. |
|
|
80
|
+
| Style image chips | Render `[Image #N]` placeholders as distinct chips. |
|
|
81
|
+
| Show attachment count | Show a status badge when the draft has image placeholders. |
|
|
77
82
|
|
|
78
|
-
### Session
|
|
83
|
+
### Session naming
|
|
79
84
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
-
|
|
84
|
-
-
|
|
85
|
+
| Setting | What it does |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| Enable /rename command | Register the `/rename` command. |
|
|
88
|
+
| Auto-name new sessions | Generate a friendly session name from the first prompt. |
|
|
89
|
+
| Auto-rename model | Model used for title generation. |
|
|
90
|
+
| Auto-rename fallback model | Model tried when the primary fails. |
|
|
91
|
+
| Deterministic fallback | Title-case words, truncated prompt, or none if all model calls fail. |
|
|
92
|
+
| Auto-rename prefix | Optional static prefix on every generated name. |
|
|
93
|
+
| Notify on auto-rename | Show a notification when auto-renaming. |
|
|
85
94
|
|
|
86
|
-
|
|
95
|
+
Advanced: input cap, title length, output tokens, timeout, custom prompt template, and debug logging.
|
|
87
96
|
|
|
88
|
-
|
|
89
|
-
- `sessionSearch.shortcutKey`: shortcut to open search; default `f2`, set `none` to disable.
|
|
90
|
-
- Search ordering: empty search shows prompts by recency; typed search sorts by match quality, then prompt recency.
|
|
91
|
-
- Result/layout knobs: `resultLimit`, `maxVisible`, `messageMaxVisible`, `previewSnippets`, `overlayWidth`, `cacheTtlSeconds`.
|
|
92
|
-
- Summary knobs: `summaryModel`, `summaryMaxTokens`, `summaryInputMaxChars`.
|
|
97
|
+
### Handoff
|
|
93
98
|
|
|
94
|
-
|
|
99
|
+
| Setting | What it does |
|
|
100
|
+
| --- | --- |
|
|
101
|
+
| Enable /handoff command | Register the `/handoff` command. |
|
|
102
|
+
| Review handoff prompt | Open an editor to edit the generated prompt before creating the session. |
|
|
103
|
+
|
|
104
|
+
### Context window
|
|
105
|
+
|
|
106
|
+
| Setting | What it does |
|
|
107
|
+
| --- | --- |
|
|
108
|
+
| Enable /context command | Register `/context`. |
|
|
95
109
|
|
|
96
|
-
###
|
|
110
|
+
### Session search
|
|
97
111
|
|
|
98
|
-
|
|
112
|
+
| Setting | What it does |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| Enable session search | Register `/search` and the overlay. |
|
|
115
|
+
| Session search shortcut | Shortcut to open search. Default `f2`; set to `none` to disable. |
|
|
116
|
+
| Result limit | Max matching prompts returned. |
|
|
117
|
+
| Visible session rows | Rows shown before scrolling. |
|
|
118
|
+
| Preview snippets | Matching snippets shown on the preview screen. |
|
|
119
|
+
| Session cache TTL | Seconds before the session list refreshes; `0` keeps it until you run `/search:refresh`. |
|
|
120
|
+
|
|
121
|
+
Summary settings (model, max tokens, input cap) tune the summarizer when you import context from a previous session.
|
|
99
122
|
|
|
100
123
|
### Notifications
|
|
101
124
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
125
|
+
Master toggle: **Enable notifications**.
|
|
126
|
+
|
|
127
|
+
Triggers (notify when): ready, direction needed, question popups, all tasks complete, critical/blocked.
|
|
128
|
+
|
|
129
|
+
Channels: terminal bell, native terminal notifications (OSC 777/99 or Windows toast), tmux `display-message`, tmux window marking, OSC passthrough, and an optional in-Pi UI notice.
|
|
130
|
+
|
|
131
|
+
Tuning: cooldown seconds, title, ready message, body length, tmux durations.
|
|
132
|
+
|
|
133
|
+
Notes:
|
|
134
|
+
|
|
135
|
+
- **Terminal notification protocol** picks between OSC 99 (Kitty) and OSC 777 automatically.
|
|
136
|
+
- **Bell when tmux window active** is off so you don't get bells while looking at Pi.
|
|
137
|
+
- **tmux native via client TTY** sends OSC notifications to attached tmux clients so notifications still appear when the Pi window is inactive.
|
|
106
138
|
|
|
107
|
-
Use `/qol notify-test` to verify your terminal/tmux
|
|
139
|
+
Use `/qol notify-test` to verify your terminal/tmux setup.
|
|
108
140
|
|
|
109
141
|
### Permission gate
|
|
110
142
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
143
|
+
| Setting | What it does |
|
|
144
|
+
| --- | --- |
|
|
145
|
+
| Prompt before risky bash commands | Ask before bash commands matching the command list. |
|
|
146
|
+
| Commands to prompt for | Comma-separated literal fragments or `/regex/flags`. |
|
|
147
|
+
| Approval preview lines | Cap the approval-prompt preview height. |
|
|
148
|
+
| Approval preview characters | Cap the approval-prompt preview width. |
|
|
114
149
|
|
|
115
|
-
|
|
150
|
+
Off by default. When enabled, non-interactive matches are blocked.
|
|
116
151
|
|
|
117
|
-
|
|
118
|
-
- `compaction.model`: summarizer model; default `google/gemini-2.5-flash`.
|
|
119
|
-
- `compaction.profile`: `concise`, `balanced`, or `exhaustive`.
|
|
120
|
-
- `compaction.remoteEnabled` / `compaction.remoteEndpoint`: call a remote summarizer with `{ systemPrompt, prompt, maxTokens }` and expect `{ summary }`.
|
|
121
|
-
- `compaction.branchSummaryEnabled`: use the same summarizer for requested `/tree` branch summaries.
|
|
122
|
-
- `compaction.idleEnabled` and related thresholds: extension-managed idle compaction trigger.
|
|
152
|
+
### Compaction
|
|
123
153
|
|
|
124
|
-
|
|
154
|
+
| Setting | What it does |
|
|
155
|
+
| --- | --- |
|
|
156
|
+
| Custom compaction summaries | Use QOL summaries instead of Pi's default. |
|
|
157
|
+
| Compaction model | Summarizer model. |
|
|
158
|
+
| Compaction detail profile | `concise`, `balanced`, or `exhaustive`. |
|
|
159
|
+
| Include previous summary | Pass the previous summary for iterative continuity. |
|
|
160
|
+
| Fallback to Pi default compaction | Run Pi's default compaction if QOL's fails. |
|
|
161
|
+
| Show compaction notifications | Notify on compaction start/fail/complete. |
|
|
162
|
+
| Custom branch summaries | Use the QOL summarizer for `/tree` branch summaries. |
|
|
163
|
+
| Remote compaction endpoint | Call a remote HTTP summarizer instead of a model. |
|
|
164
|
+
| Idle compaction trigger | Auto-compact after the session sits idle above a token threshold. |
|
|
125
165
|
|
|
126
|
-
|
|
166
|
+
Idle thresholds (token threshold, idle delay, fixed token limit, percent limit) tune when idle compaction fires.
|
|
127
167
|
|
|
128
|
-
|
|
168
|
+
### Thinking
|
|
129
169
|
|
|
130
|
-
|
|
170
|
+
| Setting | What it does |
|
|
171
|
+
| --- | --- |
|
|
172
|
+
| Hidden thinking label | Label shown when thinking blocks are hidden. |
|
|
173
|
+
| Show thinking timer | Show elapsed time next to collapsed `Thinking...` labels. |
|
|
174
|
+
| Working indicator mode | `animated` ticks every 80ms; switch to `static` if you see flashes when the chat overflows. |
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export function textFromContent(content: unknown): string {
|
|
4
|
+
if (typeof content === "string") return content;
|
|
5
|
+
if (!Array.isArray(content)) return "";
|
|
6
|
+
return content
|
|
7
|
+
.map((part) => {
|
|
8
|
+
if (typeof part === "string") return part;
|
|
9
|
+
if (part && typeof part === "object" && (part as any).type === "text" && typeof (part as any).text === "string") return (part as any).text;
|
|
10
|
+
return "";
|
|
11
|
+
})
|
|
12
|
+
.filter(Boolean)
|
|
13
|
+
.join("\n");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function lastAssistantTextFromAgentEnd(event: any, ctx: ExtensionContext): string {
|
|
17
|
+
const eventMessages = Array.isArray(event?.messages) ? event.messages : [];
|
|
18
|
+
for (let index = eventMessages.length - 1; index >= 0; index -= 1) {
|
|
19
|
+
const message = eventMessages[index];
|
|
20
|
+
if (message?.role === "assistant") return textFromContent(message.content);
|
|
21
|
+
}
|
|
22
|
+
const branch = ctx.sessionManager.getBranch?.() ?? [];
|
|
23
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
24
|
+
const entry = branch[index] as any;
|
|
25
|
+
if (entry?.type === "message" && entry.message?.role === "assistant") return textFromContent(entry.message.content);
|
|
26
|
+
}
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function needsDirection(text: string): boolean {
|
|
31
|
+
return /\?\s*$|\b(let me know|tell me|which (one|option)|choose|confirm|approve|should i|would you like|do you want|need your input|awaiting|next step)\b/i.test(text);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function criticalInfo(text: string): string | undefined {
|
|
35
|
+
const match = text.match(/\b(critical|urgent|warning|blocked|cannot proceed|security|vulnerab|secret|credential|rate limit|context (overflow|full)|manual action required)\b/i);
|
|
36
|
+
if (!match) return undefined;
|
|
37
|
+
const line = text.split(/\r?\n/).find((candidate) => candidate.toLowerCase().includes(match[0].toLowerCase())) ?? text;
|
|
38
|
+
return line.trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function taskStats(state: any): { completed: number; remaining: number; total: number } | undefined {
|
|
42
|
+
const tasks = Array.isArray(state?.tasks) ? state.tasks : undefined;
|
|
43
|
+
if (!tasks) return undefined;
|
|
44
|
+
const total = tasks.length;
|
|
45
|
+
const completed = tasks.filter((task: any) => task?.status === "completed").length;
|
|
46
|
+
const remaining = tasks.filter((task: any) => task?.status === "pending" || task?.status === "in_progress").length;
|
|
47
|
+
return { completed, remaining, total };
|
|
48
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { readPiAgentsStatuslineBridge } from "./bridges.js";
|
|
3
|
+
|
|
4
|
+
type AgentAsciiColor = "red" | "green" | "yellow" | "blue" | "magenta" | "cyan";
|
|
5
|
+
|
|
6
|
+
const AGENT_ASCII_COLOR_SEQUENCE: AgentAsciiColor[] = ["magenta", "green", "blue", "cyan", "yellow", "red"];
|
|
7
|
+
const AGENT_ASCII_BG: Record<AgentAsciiColor, number> = {
|
|
8
|
+
red: 41,
|
|
9
|
+
green: 42,
|
|
10
|
+
yellow: 43,
|
|
11
|
+
blue: 44,
|
|
12
|
+
magenta: 45,
|
|
13
|
+
cyan: 46,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function normalizeAgentAsciiColor(value: string | undefined): AgentAsciiColor | undefined {
|
|
17
|
+
const normalized = value?.trim().toLowerCase().replace(/[^a-z]/g, "");
|
|
18
|
+
switch (normalized) {
|
|
19
|
+
case "red": return "red";
|
|
20
|
+
case "green": return "green";
|
|
21
|
+
case "yellow":
|
|
22
|
+
case "orange": return "yellow";
|
|
23
|
+
case "blue": return "blue";
|
|
24
|
+
case "magenta":
|
|
25
|
+
case "purple":
|
|
26
|
+
case "violet": return "magenta";
|
|
27
|
+
case "cyan":
|
|
28
|
+
case "teal": return "cyan";
|
|
29
|
+
default: return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fallbackAgentAsciiColor(name: string): AgentAsciiColor {
|
|
34
|
+
let hash = 0;
|
|
35
|
+
for (const char of name) hash = ((hash * 31) + char.charCodeAt(0)) >>> 0;
|
|
36
|
+
return AGENT_ASCII_COLOR_SEQUENCE[hash % AGENT_ASCII_COLOR_SEQUENCE.length] ?? "magenta";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ansiAgentBg(color: AgentAsciiColor, text: string): string {
|
|
40
|
+
return `\x1b[30;${AGENT_ASCII_BG[color]}m${text}\x1b[39;49m`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function subagentStatuslineMarker(cwd: string, maxInnerWidth = 24): { plain: string; styled: string } | undefined {
|
|
44
|
+
const bridgeInfo = readPiAgentsStatuslineBridge()?.getCurrentSubagent(cwd);
|
|
45
|
+
const envName = process.env.PI_SUBAGENT_CHILD_AGENT?.trim();
|
|
46
|
+
const rawName = bridgeInfo?.name?.trim() || envName;
|
|
47
|
+
if (!rawName) return undefined;
|
|
48
|
+
const color = normalizeAgentAsciiColor(bridgeInfo?.color) ?? normalizeAgentAsciiColor(process.env.PI_SUBAGENT_CHILD_COLOR) ?? fallbackAgentAsciiColor(rawName);
|
|
49
|
+
const inner = truncateToWidth(rawName, Math.max(1, maxInnerWidth), "…");
|
|
50
|
+
const plain = ` ${inner} `;
|
|
51
|
+
return { plain, styled: ansiAgentBg(color, plain) };
|
|
52
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const ANSI_PATTERN = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
|
|
2
|
+
|
|
3
|
+
export const ANSI_ESCAPE_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
4
|
+
export const ANSI_GREEN_FG = "\x1b[32m";
|
|
5
|
+
export const ANSI_RED_FG = "\x1b[31m";
|
|
6
|
+
export const ANSI_YELLOW_FG = "\x1b[33m";
|
|
7
|
+
export const ANSI_FG_RESET = "\x1b[39m";
|
|
8
|
+
|
|
9
|
+
export function stripAnsi(text: string): string {
|
|
10
|
+
return text.replace(ANSI_PATTERN, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ansiGreen(text: string): string {
|
|
14
|
+
return `${ANSI_GREEN_FG}${text}${ANSI_FG_RESET}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ansiRed(text: string): string {
|
|
18
|
+
return `${ANSI_RED_FG}${text}${ANSI_FG_RESET}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ansiYellow(text: string): string {
|
|
22
|
+
return `${ANSI_YELLOW_FG}${text}${ANSI_FG_RESET}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function oneLine(text: string): string {
|
|
26
|
+
return text
|
|
27
|
+
.replace(ANSI_ESCAPE_RE, "")
|
|
28
|
+
.replace(/[\r\n\t]+/g, " ")
|
|
29
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, " ")
|
|
30
|
+
.replace(/\s+/g, " ")
|
|
31
|
+
.trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface VisibleMap {
|
|
35
|
+
text: string;
|
|
36
|
+
rawIndexByVisibleIndex: number[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface VisibleReplacement {
|
|
40
|
+
start: number;
|
|
41
|
+
end: number;
|
|
42
|
+
text: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ansiSequenceEnd(input: string, start: number): number {
|
|
46
|
+
const introducer = input[start + 1];
|
|
47
|
+
if (introducer == null) return start + 1;
|
|
48
|
+
|
|
49
|
+
// OSC, DCS, APC, PM, SOS strings are zero-width and end in ST (ESC \\).
|
|
50
|
+
// OSC may also end in BEL.
|
|
51
|
+
if (introducer === "]" || introducer === "P" || introducer === "_" || introducer === "^" || introducer === "X") {
|
|
52
|
+
const st = input.indexOf("\x1b\\", start + 2);
|
|
53
|
+
const bell = introducer === "]" ? input.indexOf("\x07", start + 2) : -1;
|
|
54
|
+
if (bell >= 0 && (st < 0 || bell < st)) return bell + 1;
|
|
55
|
+
return st >= 0 ? st + 2 : input.length;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (introducer === "[") {
|
|
59
|
+
let index = start + 2;
|
|
60
|
+
while (index < input.length) {
|
|
61
|
+
const code = input.charCodeAt(index);
|
|
62
|
+
if (code >= 0x40 && code <= 0x7e) return index + 1;
|
|
63
|
+
index += 1;
|
|
64
|
+
}
|
|
65
|
+
return input.length;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return Math.min(start + 2, input.length);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildVisibleMap(input: string): VisibleMap {
|
|
72
|
+
const rawIndexByVisibleIndex: number[] = [0];
|
|
73
|
+
let text = "";
|
|
74
|
+
let index = 0;
|
|
75
|
+
|
|
76
|
+
while (index < input.length) {
|
|
77
|
+
if (input.charCodeAt(index) === 0x1b) {
|
|
78
|
+
index = ansiSequenceEnd(input, index);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (rawIndexByVisibleIndex[text.length] === undefined) rawIndexByVisibleIndex[text.length] = index;
|
|
83
|
+
text += input[index] ?? "";
|
|
84
|
+
index += 1;
|
|
85
|
+
rawIndexByVisibleIndex[text.length] = index;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (rawIndexByVisibleIndex[text.length] === undefined) rawIndexByVisibleIndex[text.length] = index;
|
|
89
|
+
return { rawIndexByVisibleIndex, text };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function applyVisibleReplacements(input: string, map: VisibleMap, replacements: VisibleReplacement[]): string {
|
|
93
|
+
if (replacements.length === 0) return input;
|
|
94
|
+
|
|
95
|
+
const sorted = replacements
|
|
96
|
+
.filter((replacement) => replacement.end > replacement.start)
|
|
97
|
+
.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
98
|
+
|
|
99
|
+
let output = "";
|
|
100
|
+
let lastRawIndex = 0;
|
|
101
|
+
let lastVisibleIndex = 0;
|
|
102
|
+
|
|
103
|
+
for (const replacement of sorted) {
|
|
104
|
+
if (replacement.start < lastVisibleIndex) continue;
|
|
105
|
+
const rawStart = map.rawIndexByVisibleIndex[replacement.start];
|
|
106
|
+
const rawEnd = map.rawIndexByVisibleIndex[replacement.end];
|
|
107
|
+
if (rawStart == null || rawEnd == null || rawStart < lastRawIndex) continue;
|
|
108
|
+
|
|
109
|
+
output += input.slice(lastRawIndex, rawStart) + replacement.text;
|
|
110
|
+
lastRawIndex = rawEnd;
|
|
111
|
+
lastVisibleIndex = replacement.end;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return output + input.slice(lastRawIndex);
|
|
115
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CAVEMAN_BRIDGE_SYMBOL,
|
|
3
|
+
PI_AGENTS_STATUSLINE_SYMBOL,
|
|
4
|
+
QUESTION_SERVICE_SYMBOL,
|
|
5
|
+
VSTACK_MODAL_LOCK_SYMBOL,
|
|
6
|
+
} from "./constants.js";
|
|
7
|
+
|
|
8
|
+
export interface CavemanBridge {
|
|
9
|
+
isActive(): boolean;
|
|
10
|
+
getMode(): string;
|
|
11
|
+
getConfiguredMode?(cwd?: string): string;
|
|
12
|
+
getLastActiveMode(): string;
|
|
13
|
+
hasSessionOverride?(): boolean;
|
|
14
|
+
isStatusBadgeEnabled?(cwd?: string): boolean;
|
|
15
|
+
cycleMode?(cwd?: string): string;
|
|
16
|
+
setMode?(mode: string, cwd?: string): string | undefined;
|
|
17
|
+
subscribe(listener: () => void): () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PiAgentsStatuslineBridge {
|
|
21
|
+
getCurrentSubagent(cwd?: string): { name: string; color?: string } | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface QuestionRequestLike {
|
|
25
|
+
header?: string;
|
|
26
|
+
question?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface QuestionOpenedEventLike {
|
|
30
|
+
requestId?: string;
|
|
31
|
+
request?: QuestionRequestLike;
|
|
32
|
+
source?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface QuestionServiceLike {
|
|
36
|
+
listPending(): unknown[];
|
|
37
|
+
subscribe(listener: (event: any) => void): () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface VstackModalLock {
|
|
41
|
+
depth: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function readCavemanBridge(): CavemanBridge | undefined {
|
|
45
|
+
const host = globalThis as unknown as Record<PropertyKey, unknown>;
|
|
46
|
+
const value = host[CAVEMAN_BRIDGE_SYMBOL];
|
|
47
|
+
return value && typeof value === "object" ? (value as CavemanBridge) : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readPiAgentsStatuslineBridge(): PiAgentsStatuslineBridge | undefined {
|
|
51
|
+
const host = globalThis as unknown as Record<PropertyKey, unknown>;
|
|
52
|
+
const value = host[PI_AGENTS_STATUSLINE_SYMBOL];
|
|
53
|
+
return value && typeof value === "object" && typeof (value as PiAgentsStatuslineBridge).getCurrentSubagent === "function"
|
|
54
|
+
? (value as PiAgentsStatuslineBridge)
|
|
55
|
+
: undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getQuestionService(): QuestionServiceLike | undefined {
|
|
59
|
+
const service = (globalThis as unknown as Record<PropertyKey, unknown>)[QUESTION_SERVICE_SYMBOL];
|
|
60
|
+
if (!service || typeof service !== "object") return undefined;
|
|
61
|
+
const candidate = service as Partial<QuestionServiceLike>;
|
|
62
|
+
if (typeof candidate.subscribe === "function" && typeof candidate.listPending === "function") return candidate as QuestionServiceLike;
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function acquireVstackModalLock(): () => void {
|
|
67
|
+
const host = globalThis as unknown as Record<PropertyKey, unknown>;
|
|
68
|
+
const existing = host[VSTACK_MODAL_LOCK_SYMBOL] as VstackModalLock | undefined;
|
|
69
|
+
const lock = existing && typeof existing.depth === "number" ? existing : { depth: 0 };
|
|
70
|
+
host[VSTACK_MODAL_LOCK_SYMBOL] = lock;
|
|
71
|
+
lock.depth += 1;
|
|
72
|
+
let released = false;
|
|
73
|
+
return () => {
|
|
74
|
+
if (released) return;
|
|
75
|
+
released = true;
|
|
76
|
+
lock.depth = Math.max(0, lock.depth - 1);
|
|
77
|
+
};
|
|
78
|
+
}
|