pi-optchat 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,9 +44,9 @@ For headless use, pass `--optchat-profile work`.
44
44
  | `/optchat` | Status and actions menu. |
45
45
  | `/optchat profile` | Select or create a profile. Switching starts a fresh Pi session. |
46
46
  | `/optchat settings` | This profile's settings: models, subagent levels and limits, previous exchange, summary size tolerance. |
47
- | `/optchat model` | Compactor model and effort for this profile. |
47
+ | `/optchat model` | Compactor model and effort for this profile. Type to filter the models you are logged in to; the current one is marked. |
48
48
  | `/optchat agents` | Live agent tree and saved run history. |
49
- | `/optchat agents model` | Subagent model and effort for this profile. |
49
+ | `/optchat agents model` | Subagent model and effort for this profile, picked the same way. |
50
50
  | `/optchat usage` | Token usage and cost estimates. |
51
51
  | `/optchat activity` | Memory gauge: view size, summaries catching up, running agents. |
52
52
  | `/optchat instructions` | Edit this profile's `AGENTS.md`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ import { parseSkillBlock, type ExtensionAPI, type ExtensionContext, type Extensi
9
9
  import { Type } from 'typebox';
10
10
  import { Memory } from './memory.ts';
11
11
  import { createCompressor } from './compactor.ts';
12
- import { atomicWrite, createProfile, instructions, lastProfile, listProfiles, loadConfig, lockProfile, profilePath, rememberProfile, saveConfig, ProfileBusyError, THINKING, type ProfileConfig } from './profiles.ts';
12
+ import { atomicWrite, createProfile, instructions, lastProfile, listProfiles, loadConfig, lockProfile, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
13
13
  import { MASTER, VIEW_DOC } from './prompts.ts';
14
14
  import { cachePayload, record } from './cache.ts';
15
15
  import { asUser, boundedMessage, buildContext, logMessage, previousExchange, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
@@ -30,7 +30,7 @@ import { serveWindows } from './window-bridge.ts';
30
30
  import { openConnectedWindow, registerConnectedRenderer } from './connected-window.ts';
31
31
  import { createHandoffSummarizer } from './handoff.ts';
32
32
  import { mainTitle, TabTitle } from './title.ts';
33
- import { showSettings } from './settings-page.ts';
33
+ import { showModelPicker, showSettings } from './settings-page.ts';
34
34
 
35
35
  const binding = 'optchat.profile';
36
36
  const CONTINUITY = '\n\nFor conversational continuity, the memory view may be followed by the immediately preceding completed exchange (its user requests and final answer, in full text; left out when very long), then the new input. Use that exact wording to understand follow-ups; older exchanges and previous tool output remain accessible through memory and zoom.';
@@ -357,20 +357,15 @@ export default function optchat(pi: ExtensionAPI) {
357
357
  async execute(_id, args) { return result(await required().children.tell(args.id, args.message)); },
358
358
  });
359
359
 
360
+ /** The settings page's options: every model Pi is logged in to, with the thinking levels it takes, sorted so each provider's models sit together. */
361
+ const settingsOptions = (ctx: ExtensionContext, a: Active) => ({ profile: a.name, config: a.config,
362
+ models: ctx.modelRegistry.getAvailable().map(m => ({ name: `${m.provider}/${m.id}`, thinking: getSupportedThinkingLevels(m) })).sort((x, y) => x.name.localeCompare(y.name)),
363
+ save: (config: ProfileConfig) => { saveConfig(a.dir, config); syncSearch(config); } });
360
364
  const pickModel = async (ctx: ExtensionContext, role: 'compactor' | 'subagent') => {
361
- const a = required(), current = a.config[role];
362
- const available = ctx.modelRegistry.getAvailable(), choices = available.map(m => `${m.provider}/${m.id}`);
363
- choices.sort((a, b) => Number(b === `${current.provider}/${current.model}`) - Number(a === `${current.provider}/${current.model}`) || a.localeCompare(b));
364
- const selected = await ctx.ui.select(`${a.name}: ${role} model`, choices);
365
- if (!selected) return;
366
- const model = available.find(m => `${m.provider}/${m.id}` === selected);
367
- const levels = model ? getSupportedThinkingLevels(model) : THINKING;
368
- const picked = await ctx.ui.select('Thinking level', [...levels]);
369
- const thinking = levels.find(level => level === picked);
370
- if (!thinking) return;
371
- const separator = selected.indexOf('/');
372
- a.config[role] = { provider: selected.slice(0, separator), model: selected.slice(separator + 1), thinking };
373
- saveConfig(a.dir, a.config); ctx.ui.notify(`${role}: ${selected} (${thinking}); applies to new calls.`, 'info');
365
+ const a = required();
366
+ if (ctx.mode !== 'tui') throw new Error('Choosing a model requires interactive Pi. Edit config.json in the profile directory instead.');
367
+ const choice = await showModelPicker(ctx, role, settingsOptions(ctx, a));
368
+ if (choice) ctx.ui.notify(`${role}: ${choice.provider}/${choice.model} (${choice.thinking}); applies to new calls.`, 'info');
374
369
  };
375
370
  const inspect = async (ctx: ExtensionContext, page: InspectorPage) => {
376
371
  if (inspectorController) return;
@@ -451,8 +446,7 @@ export default function optchat(pi: ExtensionAPI) {
451
446
  if (action === 'settings') {
452
447
  const a = required();
453
448
  if (ctx.mode !== 'tui') throw new Error('/optchat settings requires interactive Pi. Edit config.json in the profile directory instead.');
454
- return showSettings(ctx, { profile: a.name, config: a.config, models: ctx.modelRegistry.getAvailable().map(m => ({ name: `${m.provider}/${m.id}`, thinking: getSupportedThinkingLevels(m) })).sort((a, b) => a.name.localeCompare(b.name)),
455
- save: config => { saveConfig(a.dir, config); syncSearch(config); } });
449
+ return showSettings(ctx, settingsOptions(ctx, a));
456
450
  }
457
451
  if (action === 'model') return pickModel(ctx, 'compactor');
458
452
  if (action === 'agents model') return pickModel(ctx, 'subagent');
@@ -71,7 +71,7 @@ class ModelStep extends Container {
71
71
  }
72
72
  private pickModel() {
73
73
  const { theme, current } = this, filter = new Input();
74
- const items: SelectItem[] = this.models.map(({ name }) => ({ value: name, label: name }));
74
+ const items: SelectItem[] = this.models.map(({ name }) => ({ value: name, label: name, description: name === modelName(current) ? 'current' : undefined }));
75
75
  let list = new SelectList(items, 10, listTheme(theme));
76
76
  const build = () => {
77
77
  list = new SelectList(filter.getValue() ? fuzzyFilter(items, filter.getValue(), i => i.value) : items, 10, listTheme(theme));
@@ -106,6 +106,21 @@ class ModelStep extends Container {
106
106
  handleInput(data: string) { this.active.handleInput(data); }
107
107
  }
108
108
 
109
+ /** Saves a change to the profile config and applies it in memory; returns the reason if it couldn't be written. */
110
+ function update(o: Options, patch: Partial<ProfileConfig>) {
111
+ try { o.save({ ...o.config, ...patch }); }
112
+ catch (error) { return `Could not save: ${error instanceof Error ? error.message : String(error)}`; }
113
+ Object.assign(o.config, patch);
114
+ return undefined;
115
+ }
116
+ /** Bordered, titled like Pi's own dialogs. */
117
+ function frame(theme: Theme, title: string, profile: string, body: Component[]) {
118
+ const page = new Container();
119
+ for (const child of [new DynamicBorder(s => theme.fg('border', s)), new Text(`${theme.bold(theme.fg('accent', title))}${theme.fg('muted', ` · ${profile}`)}`, 1, 0),
120
+ ...body, new DynamicBorder(s => theme.fg('border', s))]) page.addChild(child);
121
+ return page;
122
+ }
123
+
109
124
  /** The page itself: Pi's settings list over this profile's config, one row per setting, saved on change. */
110
125
  export function settingsPage(theme: Theme, o: Options, close: () => void, redraw = () => {}) {
111
126
  const { config } = o;
@@ -113,11 +128,9 @@ export function settingsPage(theme: Theme, o: Options, close: () => void, redraw
113
128
  const marker = (same: boolean, fallback: string) => theme.fg('dim', same ? ' default' : ` default ${fallback}`);
114
129
  const value = (key: SettingKey, current: number | boolean) => `${show(key, current)}${marker(current === SETTINGS[key].default, show(key, SETTINGS[key].default))}`;
115
130
  const apply = (patch: Partial<ProfileConfig>, what: string, applies: string) => {
116
- try { o.save({ ...config, ...patch }); }
117
- catch (error) { return `Could not save: ${error instanceof Error ? error.message : String(error)}`; }
118
- Object.assign(config, patch);
119
- notice.setText(theme.fg('success', `Saved ${what}. `) + theme.fg('muted', applies));
120
- return undefined;
131
+ const problem = update(o, patch);
132
+ if (!problem) notice.setText(theme.fg('success', `Saved ${what}. `) + theme.fg('muted', applies));
133
+ return problem;
121
134
  };
122
135
  const models = (['compactor', 'subagent'] as const).map((role): SettingItem => {
123
136
  const { label, description, applies } = ROLES[role];
@@ -152,13 +165,25 @@ export function settingsPage(theme: Theme, o: Options, close: () => void, redraw
152
165
  const problem = apply({ [key]: on }, `${SETTINGS[key].label.toLowerCase()} ${on ? 'on' : 'off'}`, SETTINGS[key].applies);
153
166
  if (problem) { list.updateValue(key, value(key, config[key])); notice.setText(theme.fg('error', problem)); }
154
167
  }, close);
155
- const page = new Container();
156
- for (const child of [new DynamicBorder(s => theme.fg('border', s)), new Text(`${theme.bold(theme.fg('accent', 'OptChat settings'))}${theme.fg('muted', ` · ${o.profile}`)}`, 1, 0),
168
+ const page = frame(theme, 'OptChat settings', o.profile, [
157
169
  new Text(theme.fg('dim', 'Saved as you change them · Victor\'s recipe by default, except Previous exchange and Summary size tolerance'), 1, 0),
158
- new Spacer(1), list, notice, new DynamicBorder(s => theme.fg('border', s))]) page.addChild(child);
170
+ new Spacer(1), list, notice]);
159
171
  return Object.assign(page, { handleInput: (data: string) => { notice.setText(''); list.handleInput(data); redraw(); } });
160
172
  }
161
173
 
174
+ /** Just the settings page's model step, for /optchat model and /optchat agents model: closes with the saved choice, or nothing on Esc. */
175
+ export function modelPicker(theme: Theme, role: Role, o: Options, close: (choice?: ModelChoice) => void, redraw = () => {}) {
176
+ const step = new ModelStep(theme, role, o.models, o.config[role], choice => {
177
+ const problem = update(o, { [role]: choice });
178
+ if (!problem) close(choice);
179
+ return problem;
180
+ }, () => close());
181
+ return Object.assign(frame(theme, 'OptChat', o.profile, [new Spacer(1), step]), { handleInput: (data: string) => { step.handleInput(data); redraw(); } });
182
+ }
183
+
162
184
  export function showSettings(ctx: ExtensionContext, o: Options) {
163
185
  return ctx.ui.custom<void>((tui, theme, _keys, done) => settingsPage(theme, o, () => done(undefined), () => tui.requestRender()));
164
186
  }
187
+ export function showModelPicker(ctx: ExtensionContext, role: Role, o: Options) {
188
+ return ctx.ui.custom<ModelChoice | undefined>((tui, theme, _keys, done) => modelPicker(theme, role, o, done, () => tui.requestRender()));
189
+ }