@openlines/opl-pi-sht 0.1.14
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/LICENSE +21 -0
- package/README.md +191 -0
- package/configs/opl-browser.json.sample +8 -0
- package/configs/opl-footer.json +48 -0
- package/configs/opl-footer.json.sample +48 -0
- package/configs/opl-input.json +22 -0
- package/configs/opl-input.json.sample +22 -0
- package/configs/opl-modes.json +291 -0
- package/configs/opl-modes.json.sample +285 -0
- package/configs/opl-simplebench.json +8 -0
- package/configs/opl-simplebench.json.sample +8 -0
- package/configs/opl-todo.json +12 -0
- package/configs/opl-todo.json.sample +12 -0
- package/configs/opl-webaccess.json +31 -0
- package/configs/opl-webaccess.json.sample +31 -0
- package/extensions/opl-browser/README.md +83 -0
- package/extensions/opl-browser/browser.ts +185 -0
- package/extensions/opl-browser/config.ts +37 -0
- package/extensions/opl-browser/index.ts +89 -0
- package/extensions/opl-browser/package-lock.json +60 -0
- package/extensions/opl-browser/package.json +10 -0
- package/extensions/opl-ctxtrim/README.md +78 -0
- package/extensions/opl-ctxtrim/index.ts +151 -0
- package/extensions/opl-footer/README.md +193 -0
- package/extensions/opl-footer/config.ts +91 -0
- package/extensions/opl-footer/git-status.ts +170 -0
- package/extensions/opl-footer/icons.ts +49 -0
- package/extensions/opl-footer/index.ts +368 -0
- package/extensions/opl-footer/segments/caveman.ts +25 -0
- package/extensions/opl-footer/segments/chat-mode.ts +24 -0
- package/extensions/opl-footer/segments/context.ts +92 -0
- package/extensions/opl-footer/segments/cost.ts +25 -0
- package/extensions/opl-footer/segments/git.ts +47 -0
- package/extensions/opl-footer/segments/helpers.ts +18 -0
- package/extensions/opl-footer/segments/index.ts +51 -0
- package/extensions/opl-footer/segments/mode-switcher.ts +24 -0
- package/extensions/opl-footer/segments/model.ts +22 -0
- package/extensions/opl-footer/segments/path.ts +34 -0
- package/extensions/opl-footer/segments/pi.ts +11 -0
- package/extensions/opl-footer/segments/plan-mode.ts +24 -0
- package/extensions/opl-footer/segments/separator.ts +9 -0
- package/extensions/opl-footer/segments/session-stats.ts +82 -0
- package/extensions/opl-footer/segments/thinking.ts +51 -0
- package/extensions/opl-footer/segments/tokens.ts +66 -0
- package/extensions/opl-footer/theme.ts +113 -0
- package/extensions/opl-footer/types.ts +172 -0
- package/extensions/opl-init/README.md +23 -0
- package/extensions/opl-init/index.ts +451 -0
- package/extensions/opl-input/README.md +83 -0
- package/extensions/opl-input/config.ts +137 -0
- package/extensions/opl-input/index.ts +284 -0
- package/extensions/opl-input/mode-style.ts +38 -0
- package/extensions/opl-input/utils.ts +378 -0
- package/extensions/opl-modes/README.md +132 -0
- package/extensions/opl-modes/config.ts +473 -0
- package/extensions/opl-modes/index.ts +1081 -0
- package/extensions/opl-modes/menus.ts +60 -0
- package/extensions/opl-modes/state.ts +152 -0
- package/extensions/opl-modes/types.ts +164 -0
- package/extensions/opl-modes/utils.ts +138 -0
- package/extensions/opl-questionnaire/README.md +26 -0
- package/extensions/opl-questionnaire/index.ts +445 -0
- package/extensions/opl-questionnaire/types.ts +55 -0
- package/extensions/opl-simplebench/README.md +242 -0
- package/extensions/opl-simplebench/artifact.ts +41 -0
- package/extensions/opl-simplebench/benchmark.ts +873 -0
- package/extensions/opl-simplebench/coding.ts +282 -0
- package/extensions/opl-simplebench/index.ts +203 -0
- package/extensions/opl-simplebench/llama-server.ts +290 -0
- package/extensions/opl-simplebench/metrics.ts +73 -0
- package/extensions/opl-simplebench/report.ts +46 -0
- package/extensions/opl-simplebench/research.ts +200 -0
- package/extensions/opl-simplebench/scoring.ts +48 -0
- package/extensions/opl-simplebench/tests.ts +39 -0
- package/extensions/opl-simplebench/types.ts +6 -0
- package/extensions/opl-simplebench/util/config.ts +170 -0
- package/extensions/opl-simplebench/util/debug.ts +32 -0
- package/extensions/opl-simplebench/util/format.ts +402 -0
- package/extensions/opl-simplebench/util/providers.ts +841 -0
- package/extensions/opl-todo/README.md +43 -0
- package/extensions/opl-todo/config.ts +71 -0
- package/extensions/opl-todo/index.ts +415 -0
- package/extensions/opl-webaccess/README.md +47 -0
- package/extensions/opl-webaccess/config.ts +55 -0
- package/extensions/opl-webaccess/extract.ts +83 -0
- package/extensions/opl-webaccess/index.ts +232 -0
- package/extensions/opl-webaccess/package-lock.json +345 -0
- package/extensions/opl-webaccess/package.json +18 -0
- package/extensions/opl-webaccess/pdf.ts +8 -0
- package/extensions/opl-webaccess/providers/ddgs.ts +72 -0
- package/extensions/opl-webaccess/providers/exa.ts +74 -0
- package/extensions/opl-webaccess/providers/gemini.ts +60 -0
- package/extensions/opl-webaccess/providers/searxng.ts +86 -0
- package/extensions/opl-webaccess/providers/tavily.ts +60 -0
- package/extensions/opl-webaccess/search.ts +36 -0
- package/extensions/opl-webaccess/storage.ts +51 -0
- package/extensions/opl-webaccess/turndown.d.ts +1 -0
- package/extensions/opl-webaccess/types.ts +26 -0
- package/extensions/opl-webaccess/utils.ts +28 -0
- package/package.json +55 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Shared select-menu dialog: bordered title + SelectList + help footer. */
|
|
2
|
+
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Container, Text, Spacer, SelectList, type SelectItem } from "@earendil-works/pi-tui";
|
|
6
|
+
|
|
7
|
+
export interface SelectMenuOptions {
|
|
8
|
+
/** Render the title bold (default true). */
|
|
9
|
+
bold?: boolean;
|
|
10
|
+
/** Dim suffix appended after the title, e.g. a warning hint. */
|
|
11
|
+
dimSuffix?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Show a bordered select menu and resolve with the chosen item's value, or null when cancelled. */
|
|
15
|
+
export function showSelectMenu(
|
|
16
|
+
ctx: ExtensionContext,
|
|
17
|
+
title: string,
|
|
18
|
+
items: SelectItem[],
|
|
19
|
+
opts: SelectMenuOptions = {},
|
|
20
|
+
): Promise<string | null> {
|
|
21
|
+
const { bold = true, dimSuffix } = opts;
|
|
22
|
+
|
|
23
|
+
return ctx.ui.custom<string | null>((tui, theme, kb, done) => {
|
|
24
|
+
const border = new DynamicBorder((s: string) => theme.fg("border", s));
|
|
25
|
+
const titleText = theme.fg("text", bold ? theme.bold(title) : title) + (dimSuffix ? theme.fg("dim", dimSuffix) : "");
|
|
26
|
+
const titleLine = new Text(titleText, 1, 0);
|
|
27
|
+
|
|
28
|
+
const selectList = new SelectList(items, Math.min(items.length, 10), {
|
|
29
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
30
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
31
|
+
description: (t) => theme.fg("muted", t),
|
|
32
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
33
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
34
|
+
});
|
|
35
|
+
selectList.onSelect = (item) => done(item.value);
|
|
36
|
+
selectList.onCancel = () => done(null);
|
|
37
|
+
|
|
38
|
+
const help = new Text(theme.fg("dim", "↑↓") + theme.fg("muted", " navigate ") + theme.fg("dim", "enter") + theme.fg("muted", " select ") + theme.fg("dim", "esc") + theme.fg("muted", " cancel"), 1, 0);
|
|
39
|
+
|
|
40
|
+
const container = new Container();
|
|
41
|
+
container.addChild(border);
|
|
42
|
+
container.addChild(new Spacer());
|
|
43
|
+
container.addChild(titleLine);
|
|
44
|
+
container.addChild(new Spacer());
|
|
45
|
+
container.addChild(selectList);
|
|
46
|
+
container.addChild(new Spacer());
|
|
47
|
+
container.addChild(help);
|
|
48
|
+
container.addChild(border);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
render(width: number) { return container.render(width); },
|
|
52
|
+
invalidate() { container.invalidate(); },
|
|
53
|
+
handleInput(data: string) {
|
|
54
|
+
if (kb.matches(data, "app.tools.expand")) { ctx.ui.setToolsExpanded(!ctx.ui.getToolsExpanded()); return; }
|
|
55
|
+
selectList.handleInput(data);
|
|
56
|
+
tui.requestRender();
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/** OFF/CHAT/PLAN/EXECUTE unified state machine with session persistence via appendEntry blob. */
|
|
2
|
+
|
|
3
|
+
import { ENTRY_TYPE, getModeDefinition } from "./config.js";
|
|
4
|
+
import type { AgentMode, AgentModeBlob, ModeAppearanceConfig } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/** Mutable state — shared within the extension module. */
|
|
7
|
+
const state: {
|
|
8
|
+
mode: AgentMode;
|
|
9
|
+
activePlanFile: string | null;
|
|
10
|
+
refining: boolean;
|
|
11
|
+
refineCount: number;
|
|
12
|
+
} = {
|
|
13
|
+
mode: "off",
|
|
14
|
+
activePlanFile: null,
|
|
15
|
+
refining: false,
|
|
16
|
+
refineCount: 0,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Publish unified global and legacy globals at module load.
|
|
20
|
+
(globalThis as Record<string, unknown>).__agentMode = { mode: "off" };
|
|
21
|
+
(globalThis as Record<string, unknown>).__planMode = { mode: "off" };
|
|
22
|
+
(globalThis as Record<string, unknown>).__chatMode = { mode: "off" };
|
|
23
|
+
|
|
24
|
+
/** Derive and sync all globalThis snapshots from unified state. */
|
|
25
|
+
function syncGlobalThis(): void {
|
|
26
|
+
const m = state.mode;
|
|
27
|
+
// Unified global for the mode-switcher footer segment
|
|
28
|
+
(globalThis as Record<string, unknown>).__agentMode = {
|
|
29
|
+
mode: m,
|
|
30
|
+
appearance: getModeDefinition(m)?.appearance,
|
|
31
|
+
} satisfies { mode: AgentMode; appearance?: ModeAppearanceConfig };
|
|
32
|
+
// Legacy globals for backward compat with chat-input and footer segments
|
|
33
|
+
(globalThis as Record<string, unknown>).__planMode = { mode: m === "chat" ? "off" : m };
|
|
34
|
+
(globalThis as Record<string, unknown>).__chatMode = { mode: m === "chat" ? "chat" : "off" };
|
|
35
|
+
const requestRender = (globalThis as Record<string, unknown>).__footerRequestRender;
|
|
36
|
+
if (typeof requestRender === "function") requestRender();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getMode(): AgentMode { return state.mode; }
|
|
40
|
+
export function getActivePlanFile(): string | null { return state.activePlanFile; }
|
|
41
|
+
export function getRefining(): boolean { return state.refining; }
|
|
42
|
+
|
|
43
|
+
export function setRefining(value: boolean): void {
|
|
44
|
+
state.refining = value;
|
|
45
|
+
syncGlobalThis();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getRefineCount(): number { return state.refineCount; }
|
|
49
|
+
export function incrementRefineCount(): void { state.refineCount++; }
|
|
50
|
+
export function resetRefineCount(): void { state.refineCount = 0; }
|
|
51
|
+
|
|
52
|
+
export function setActivePlanFile(
|
|
53
|
+
file: string | null,
|
|
54
|
+
pi: { appendEntry: (type: string, data?: unknown) => void },
|
|
55
|
+
): void {
|
|
56
|
+
state.activePlanFile = file;
|
|
57
|
+
syncGlobalThis();
|
|
58
|
+
persist(pi);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function enterPlanWithFile(
|
|
62
|
+
file: string | null,
|
|
63
|
+
pi: { appendEntry: (type: string, data?: unknown) => void },
|
|
64
|
+
): void {
|
|
65
|
+
state.mode = "plan";
|
|
66
|
+
state.activePlanFile = file;
|
|
67
|
+
state.refining = false;
|
|
68
|
+
syncGlobalThis();
|
|
69
|
+
persist(pi);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function transition(
|
|
73
|
+
newMode: AgentMode,
|
|
74
|
+
pi: { appendEntry: (type: string, data?: unknown) => void },
|
|
75
|
+
): void {
|
|
76
|
+
if (newMode === "plan") state.activePlanFile = null;
|
|
77
|
+
state.mode = newMode;
|
|
78
|
+
state.refining = false;
|
|
79
|
+
state.refineCount = 0;
|
|
80
|
+
syncGlobalThis();
|
|
81
|
+
persist(pi);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function persist(pi: { appendEntry: (type: string, data?: unknown) => void }): void {
|
|
85
|
+
pi.appendEntry(ENTRY_TYPE, {
|
|
86
|
+
mode: state.mode,
|
|
87
|
+
activePlanFile: state.activePlanFile,
|
|
88
|
+
} satisfies AgentModeBlob);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Restore state from the most recent relevant session entry on the current branch.
|
|
93
|
+
* Checks for native mode-switcher entries first; falls back to legacy plan-mode/chat-mode entries
|
|
94
|
+
* so sessions saved by the old extensions are restored correctly.
|
|
95
|
+
*/
|
|
96
|
+
export function restore(
|
|
97
|
+
entries: Array<{ type: string; customType?: string; data?: unknown }>,
|
|
98
|
+
): boolean {
|
|
99
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
100
|
+
const entry = entries[i];
|
|
101
|
+
if (entry.type !== "custom") continue;
|
|
102
|
+
|
|
103
|
+
if (entry.customType === ENTRY_TYPE) {
|
|
104
|
+
const data = entry.data as AgentModeBlob | undefined;
|
|
105
|
+
if (data?.mode) {
|
|
106
|
+
state.mode = data.mode;
|
|
107
|
+
state.activePlanFile = data.activePlanFile ?? null;
|
|
108
|
+
state.refining = false;
|
|
109
|
+
syncGlobalThis();
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Backward compat: restore from legacy entries written by the old extensions.
|
|
116
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
117
|
+
const entry = entries[i];
|
|
118
|
+
if (entry.type !== "custom") continue;
|
|
119
|
+
|
|
120
|
+
if (entry.customType === "plan-mode") {
|
|
121
|
+
const data = entry.data as { mode?: string; activePlanFile?: string | null } | undefined;
|
|
122
|
+
if (data?.mode === "plan" || data?.mode === "execute") {
|
|
123
|
+
state.mode = data.mode as AgentMode;
|
|
124
|
+
state.activePlanFile = data.activePlanFile ?? null;
|
|
125
|
+
state.refining = false;
|
|
126
|
+
syncGlobalThis();
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (entry.customType === "chat-mode") {
|
|
132
|
+
const data = entry.data as { mode?: string } | undefined;
|
|
133
|
+
if (data?.mode === "chat") {
|
|
134
|
+
state.mode = "chat";
|
|
135
|
+
state.activePlanFile = null;
|
|
136
|
+
state.refining = false;
|
|
137
|
+
syncGlobalThis();
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function resetState(): void {
|
|
147
|
+
state.mode = "off";
|
|
148
|
+
state.activePlanFile = null;
|
|
149
|
+
state.refining = false;
|
|
150
|
+
state.refineCount = 0;
|
|
151
|
+
syncGlobalThis();
|
|
152
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/** Shared types for the opl-modes extension. */
|
|
2
|
+
|
|
3
|
+
/** Mode identifier — now extensible via config. Built-in: off, chat, plan, execute. */
|
|
4
|
+
export type AgentMode = string;
|
|
5
|
+
|
|
6
|
+
/** Persisted blob — plan file is the source of truth for steps. */
|
|
7
|
+
export interface AgentModeBlob {
|
|
8
|
+
mode: AgentMode;
|
|
9
|
+
activePlanFile: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PlanFileSummary {
|
|
13
|
+
name: string;
|
|
14
|
+
title: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Model reference — provider + model id, resolved via ctx.modelRegistry.find(provider, id). */
|
|
18
|
+
export interface ModeModelConfig {
|
|
19
|
+
provider: string;
|
|
20
|
+
id: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ─── User Config ─────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface CleanupUserConfig {
|
|
26
|
+
cleanupOnComplete?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UiUserConfig {
|
|
30
|
+
hideNotify?: boolean;
|
|
31
|
+
hideWidget?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ShortcutsUserConfig {
|
|
35
|
+
cycleMode?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ChatLabelUserConfig {
|
|
39
|
+
notify?: string;
|
|
40
|
+
notifyType?: string;
|
|
41
|
+
widget?: string;
|
|
42
|
+
widgetColor?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface PlanLabelUserConfig {
|
|
46
|
+
notify?: string;
|
|
47
|
+
notifyType?: string;
|
|
48
|
+
notifyWithTitle?: string;
|
|
49
|
+
notifyLoaded?: string;
|
|
50
|
+
widget?: string;
|
|
51
|
+
widgetWithTitle?: string;
|
|
52
|
+
widgetColor?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ExecuteLabelUserConfig {
|
|
56
|
+
notify?: string;
|
|
57
|
+
notifyWithTitle?: string;
|
|
58
|
+
notifyType?: string;
|
|
59
|
+
widget?: string;
|
|
60
|
+
widgetWithTitle?: string;
|
|
61
|
+
widgetColor?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface OffLabelUserConfig {
|
|
65
|
+
notify?: string;
|
|
66
|
+
notifyType?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Visual identity for a custom mode, consumed by mode-aware renderers. */
|
|
70
|
+
export interface ModeAppearanceConfig {
|
|
71
|
+
prefix?: string;
|
|
72
|
+
prefixColor?: string;
|
|
73
|
+
borderColor?: string;
|
|
74
|
+
modeColor?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Label configuration for a single mode. */
|
|
78
|
+
export interface ModeLabelConfig {
|
|
79
|
+
notify?: string;
|
|
80
|
+
notifyType?: string;
|
|
81
|
+
notifyWithTitle?: string;
|
|
82
|
+
notifyLoaded?: string;
|
|
83
|
+
widget?: string;
|
|
84
|
+
widgetWithTitle?: string;
|
|
85
|
+
widgetColor?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Legacy hardcoded labels — kept for backward compat. Prefer `modes` in user config. */
|
|
89
|
+
export interface LabelsUserConfig {
|
|
90
|
+
chat?: ChatLabelUserConfig;
|
|
91
|
+
plan?: PlanLabelUserConfig;
|
|
92
|
+
execute?: ExecuteLabelUserConfig;
|
|
93
|
+
off?: OffLabelUserConfig;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Complete definition for a single mode. */
|
|
97
|
+
export interface ModeDefinition {
|
|
98
|
+
/** System prompt injected when this mode is active. */
|
|
99
|
+
prompt: string | (() => string);
|
|
100
|
+
/** Tool names available in this mode. Omit to inherit all tools. */
|
|
101
|
+
tools?: string[];
|
|
102
|
+
/** Bash command patterns considered safe (allowed). Compiled RegExp at runtime. */
|
|
103
|
+
safePatterns?: RegExp[];
|
|
104
|
+
/** Bash command patterns considered destructive (blocked). Compiled RegExp at runtime. */
|
|
105
|
+
destructivePatterns?: RegExp[];
|
|
106
|
+
/** UI labels for this mode. */
|
|
107
|
+
labels?: ModeLabelConfig;
|
|
108
|
+
/** Custom-mode appearance published to opl-input and opl-footer. */
|
|
109
|
+
appearance?: ModeAppearanceConfig;
|
|
110
|
+
/** Whether plan_complete tool should be available. Default: false */
|
|
111
|
+
allowPlanComplete?: boolean;
|
|
112
|
+
/** Whether plan execution can be started from this mode (mode-picker "Execute:" items and /execute). Default: true. The plan-mode action menu is always available regardless of this flag. */
|
|
113
|
+
allowExecute?: boolean;
|
|
114
|
+
/** Whether to show this mode in the picker menu and cycling shortcut. Default: true */
|
|
115
|
+
visible?: boolean;
|
|
116
|
+
/** Whether this mode can be entered via the picker or cycle shortcut. Default: true. Unlike `visible`, disabling does not remove the mode — it can still be reached programmatically (e.g. execute mode via plan selection). */
|
|
117
|
+
enabled?: boolean;
|
|
118
|
+
/** Model to switch to when this mode is entered. Omit to leave the current model untouched. */
|
|
119
|
+
model?: ModeModelConfig;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** User-facing mode definition — patterns are raw strings, compiled to RegExp internally. */
|
|
123
|
+
export interface UserModeDefinition {
|
|
124
|
+
prompt?: string;
|
|
125
|
+
tools?: string[];
|
|
126
|
+
safePatterns?: string[];
|
|
127
|
+
destructivePatterns?: string[];
|
|
128
|
+
labels?: ModeLabelConfig;
|
|
129
|
+
appearance?: ModeAppearanceConfig;
|
|
130
|
+
allowPlanComplete?: boolean;
|
|
131
|
+
/** Set to false to block starting plan execution while this mode is active (hides picker "Execute:" items and blocks /execute). Default: true. */
|
|
132
|
+
allowExecute?: boolean;
|
|
133
|
+
visible?: boolean;
|
|
134
|
+
/** Set to false to disable this mode from the picker menu and cycle shortcut. Default: true. */
|
|
135
|
+
enabled?: boolean;
|
|
136
|
+
/** Model to switch to when this mode is entered. Omit to leave the current model untouched. */
|
|
137
|
+
model?: ModeModelConfig;
|
|
138
|
+
}
|
|
139
|
+
/** Partial mode definition for user config — all fields optional, patterns as raw strings. */
|
|
140
|
+
export type PartialModeDefinition = UserModeDefinition;
|
|
141
|
+
|
|
142
|
+
export interface BashPatternsUserConfig {
|
|
143
|
+
safePatterns?: string[];
|
|
144
|
+
destructivePatterns?: string[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface ModeSwitcherUserConfig {
|
|
148
|
+
cleanup?: CleanupUserConfig;
|
|
149
|
+
ui?: UiUserConfig;
|
|
150
|
+
shortcuts?: ShortcutsUserConfig;
|
|
151
|
+
/** @deprecated Use `modes` registry instead. Kept for backward compat. */
|
|
152
|
+
labels?: LabelsUserConfig;
|
|
153
|
+
bashPatterns?: BashPatternsUserConfig;
|
|
154
|
+
/** Replace-only: provide a list of tool names to replace the default CHAT mode tool set. Omit to keep defaults. */
|
|
155
|
+
chatAllowedTools?: string[];
|
|
156
|
+
/** Replace-only: provide a list of tool names to replace the default PLAN mode tool set. Omit to keep defaults. */
|
|
157
|
+
planAllowedTools?: string[];
|
|
158
|
+
/** User-defined modes that extend or override built-in modes. */
|
|
159
|
+
modes?: Record<string, PartialModeDefinition>;
|
|
160
|
+
/** Tool names withheld from the active set at rest and enabled on demand via the load_tools loader. Protected/core tools are ignored. */
|
|
161
|
+
lazyTools?: string[];
|
|
162
|
+
/** Default notify text template for custom modes that don't set their own `labels.notify`. Use {Name} as a placeholder for the mode name (capitalized). Default: "✓ {Name} mode ON". */
|
|
163
|
+
defaultNotifyTemplate?: string;
|
|
164
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/** Pure utilities: isSafeCommand, extractPlanText, plan file I/O, color helpers */
|
|
2
|
+
|
|
3
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
import { SAFE_COMMAND_PATTERNS, DESTRUCTIVE_PATTERNS, PLAN_DIR, PLAN_FILE_PREFIX } from "./config.js";
|
|
8
|
+
import type { PlanFileSummary } from "./types.js";
|
|
9
|
+
|
|
10
|
+
/** Check if command matches safe patterns and not destructive patterns. */
|
|
11
|
+
export function isSafeCommand(command: string): boolean {
|
|
12
|
+
return SAFE_COMMAND_PATTERNS.some((p) => p.test(command))
|
|
13
|
+
&& !DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const PLAN_ACTION_VERB = /^\s*\d+\.\s+(?:\*{1,2})?(?:add|creat|updat|fix|remov|refactor|implement|modif|chang|edit|writ|build|run|install|configur|set\s+up|delet|mov|renam|inject|migrat|replac|extract|test|deploy|integrat|convert)/i;
|
|
17
|
+
|
|
18
|
+
/** Heuristic: does text look like a plan? (numbered steps, checkboxes, structured). */
|
|
19
|
+
export function isPlanLike(text: string): boolean {
|
|
20
|
+
const lines = text.split('\n');
|
|
21
|
+
const numberedSteps = lines.filter(l => /^\s*\d+\.\s/.test(l));
|
|
22
|
+
if (numberedSteps.length >= 3 && text.length > 200 && lines.some(l => PLAN_ACTION_VERB.test(l))) return true;
|
|
23
|
+
const checkboxes = lines.filter(l => /^\s*- \[[ xX]\]/.test(l));
|
|
24
|
+
if (checkboxes.length >= 3 && text.length > 150) return true;
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Extract the raw text under the "Plan:" header from a message. Falls back to entire message if plan-like. Returns null if no plan found. */
|
|
29
|
+
export function extractPlanText(message: string): string | null {
|
|
30
|
+
const planMatch = message.match(/^\s*(?:#{1,6}\s*)?(?:\*{1,2})?Plan:(?:\*{1,2})?[^\n]*$/im);
|
|
31
|
+
if (planMatch) {
|
|
32
|
+
const afterPlan = message.slice(planMatch.index! + planMatch[0].length);
|
|
33
|
+
return afterPlan.trim();
|
|
34
|
+
}
|
|
35
|
+
// Fallback: if message looks like a plan, use entire message
|
|
36
|
+
if (isPlanLike(message)) return message.trim();
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Plan File I/O ────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/** Ensure .pi/plans/ exists, return its absolute path. */
|
|
43
|
+
export function ensurePlanDir(): string {
|
|
44
|
+
const dir = join(process.cwd(), PLAN_DIR);
|
|
45
|
+
mkdirSync(dir, { recursive: true });
|
|
46
|
+
return dir;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Derive display title from filename.
|
|
50
|
+
* For named plans (e.g. plan-add-auth.md) → "Add Auth".
|
|
51
|
+
* For timestamp plans (e.g. plan-2026-05-10-14-30.md) → "May 10, 2026 14:30". */
|
|
52
|
+
export function titleFromFilename(filename: string): string {
|
|
53
|
+
const stem = filename.replace(/^plan-/ , "").replace(/\.md$/, "");
|
|
54
|
+
|
|
55
|
+
// Detect timestamp pattern: YYYY-MM-DD-HH-MM
|
|
56
|
+
const tsMatch = stem.match(/^(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})$/);
|
|
57
|
+
if (tsMatch) {
|
|
58
|
+
const [, y, m, d, h, min] = tsMatch;
|
|
59
|
+
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
60
|
+
const month = months[parseInt(m, 10) - 1] ?? m;
|
|
61
|
+
return `${month} ${parseInt(d, 10)}, ${y} ${h}:${min}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Named plan: hyphens → spaces, title-case
|
|
65
|
+
return stem.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Sanitize a plan name — reject path traversal and invalid characters. Returns null if invalid. */
|
|
69
|
+
export function sanitizePlanName(name: string): string | null {
|
|
70
|
+
const trimmed = name.trim();
|
|
71
|
+
if (!trimmed) return null;
|
|
72
|
+
if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("..")) return null;
|
|
73
|
+
if (!/^[\w\s.-]+$/.test(trimmed)) return null;
|
|
74
|
+
return trimmed.replace(/\s+/g, "-");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** List available plan files in .pi/plans/ with titles from # Plan: heading. */
|
|
78
|
+
export function listPlanFiles(): PlanFileSummary[] {
|
|
79
|
+
const dir = join(process.cwd(), PLAN_DIR);
|
|
80
|
+
if (!existsSync(dir)) return [];
|
|
81
|
+
|
|
82
|
+
const files = readdirSync(dir)
|
|
83
|
+
.filter((f) => f.startsWith(PLAN_FILE_PREFIX) && f.endsWith(".md"))
|
|
84
|
+
.sort();
|
|
85
|
+
|
|
86
|
+
return files.map((filename) => {
|
|
87
|
+
const content = readFileSync(join(dir, filename), "utf-8");
|
|
88
|
+
const titleMatch = content.match(/^# Plan:\s*(.+)$/m);
|
|
89
|
+
const title = titleMatch ? titleMatch[1].trim() : titleFromFilename(filename);
|
|
90
|
+
return { name: filename, title };
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Extract text content from an LLM message (string or array of content blocks). */
|
|
95
|
+
export function extractTextFromMessage(message: Record<string, unknown>): string | null {
|
|
96
|
+
const content = message.content;
|
|
97
|
+
if (typeof content === "string") return content;
|
|
98
|
+
if (Array.isArray(content)) {
|
|
99
|
+
return content
|
|
100
|
+
.filter((block: unknown): block is { type: string; text?: string } =>
|
|
101
|
+
typeof block === "object" && block !== null && "type" in block && (block as { type: string }).type === "text",
|
|
102
|
+
)
|
|
103
|
+
.map((block) => block.text ?? "")
|
|
104
|
+
.join("\n");
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Color Utilities ────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/** Check if a color string is a hex color (e.g. "#ff6600"). */
|
|
112
|
+
export function isHexColor(color: string): boolean {
|
|
113
|
+
return color.startsWith("#");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Convert hex color string to ANSI truecolor escape. */
|
|
117
|
+
function hexToAnsi(hex: string): string {
|
|
118
|
+
const h = hex.replace("#", "");
|
|
119
|
+
if (!/^[0-9a-fA-F]{6}$/.test(h)) return "";
|
|
120
|
+
const r = parseInt(h.slice(0, 2), 16);
|
|
121
|
+
const g = parseInt(h.slice(2, 4), 16);
|
|
122
|
+
const b = parseInt(h.slice(4, 6), 16);
|
|
123
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Apply a color to text — supports pi theme tokens and hex values. */
|
|
127
|
+
export function applyLabelColor(theme: Theme, color: string, text: string): string {
|
|
128
|
+
if (isHexColor(color)) {
|
|
129
|
+
const ansi = hexToAnsi(color);
|
|
130
|
+
if (!ansi) return text;
|
|
131
|
+
return `${ansi}${text}\x1b[39m`;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
return theme.fg(color as ThemeColor, text);
|
|
135
|
+
} catch {
|
|
136
|
+
return text;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# opl-questionnaire
|
|
2
|
+
|
|
3
|
+
Adds a model-callable interactive questionnaire for discrete choices, preferences, and confirmations.
|
|
4
|
+
|
|
5
|
+
## Commands, flags, and shortcuts
|
|
6
|
+
|
|
7
|
+
- Tool: `questionnaire`.
|
|
8
|
+
- `↑`/`↓`: move through options; `1`-`9`/`0`: select options 1-10; `Enter`: select or submit.
|
|
9
|
+
- Multi-question UI: `Tab`/`Shift+Tab` or `←`/`→` changes tabs; `Esc` cancels or exits custom-answer editing.
|
|
10
|
+
|
|
11
|
+
## Extension features
|
|
12
|
+
|
|
13
|
+
- Renders one question as a compact option list and multiple questions as tabs with a final Submit tab.
|
|
14
|
+
- Questions require an `id`, prompt, options, and optional label or free-text `allowOther` response.
|
|
15
|
+
- Rejects duplicate IDs and questions that cannot be answered; returns `(no response)` for an empty custom answer.
|
|
16
|
+
- Returns structured answers containing question ID, selected value and label, custom-text flag, and option index where applicable.
|
|
17
|
+
- Blocks submission until every multi-question prompt has an answer.
|
|
18
|
+
- Returns a clear error in headless sessions so the model can use plain-text questions instead.
|
|
19
|
+
|
|
20
|
+
## Configuration
|
|
21
|
+
|
|
22
|
+
No external configuration file. Availability follows Pi's active tool set: the shipped `opl-modes` chat and plan lists include `questionnaire`; custom modes must list it explicitly.
|
|
23
|
+
|
|
24
|
+
## Architecture
|
|
25
|
+
|
|
26
|
+
`index.ts` registers the tool, validates input, renders the custom TUI, and formats results. `types.ts` contains the TypeBox parameter schema and shared question/answer types.
|