mu-coding 0.11.0 → 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mu-coding",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Minimal terminal AI assistant for local models",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,9 +24,9 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "ink": "^7.0.1",
27
- "mu-agents": "0.11.0",
28
- "mu-core": "0.11.0",
29
- "mu-openai-provider": "0.11.0",
27
+ "mu-agents": "0.13.0",
28
+ "mu-core": "0.13.0",
29
+ "mu-openai-provider": "0.13.0",
30
30
  "react": "^19.2.5"
31
31
  }
32
32
  }
@@ -7,7 +7,7 @@
7
7
  * - Must never block startup. Caller fire-and-forgets the returned promise.
8
8
  * - Must never crash the host on network / DNS errors. Every probe is wrapped.
9
9
  * - Must not hammer npm on every boot. Results are cached in
10
- * `<cacheDir>/update-check.json` for `CACHE_TTL_MS` (24h).
10
+ * `<cacheDir>/update-check.json` for `CACHE_TTL_MS` (1h).
11
11
  * - Disable with `MU_NO_UPDATE_CHECK=1` (kill switch for offline / CI / tests).
12
12
  *
13
13
  * Toasts are routed through the existing `InkUIService.notify` queue, which
@@ -16,7 +16,7 @@
16
16
  * the alert still surfaces.
17
17
  */
18
18
 
19
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
20
20
  import { join } from 'node:path';
21
21
  import { getCacheDir, getDataDir } from '../config/index';
22
22
  import type { InkUIService } from '../tui/plugins/InkUIService';
@@ -28,7 +28,7 @@ import {
28
28
  probeSelfAsync,
29
29
  } from './updateCheck';
30
30
 
31
- const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 h
31
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 h
32
32
  const CACHE_FILENAME = 'update-check.json';
33
33
 
34
34
  interface CacheShape {
@@ -61,6 +61,19 @@ function writeCache(cache: CacheShape): void {
61
61
  }
62
62
  }
63
63
 
64
+ /**
65
+ * Discard the cached probe result. Called after a `/update` so the next
66
+ * boot re-probes against fresh registry data instead of replaying the
67
+ * pre-update state from cache.
68
+ */
69
+ export function invalidateUpdateCheckCache(): void {
70
+ try {
71
+ rmSync(cachePath(), { force: true });
72
+ } catch {
73
+ // best-effort
74
+ }
75
+ }
76
+
64
77
  function isDisabled(): boolean {
65
78
  const v = process.env.MU_NO_UPDATE_CHECK;
66
79
  return v === '1' || v === 'true' || v === 'yes';
@@ -0,0 +1,30 @@
1
+ import { Box } from 'ink';
2
+ import { ToolHeader } from './ToolHeader';
3
+
4
+ interface WebFetchOutputProps {
5
+ args: string;
6
+ error: boolean;
7
+ }
8
+
9
+ function parseUrl(args: string): string {
10
+ try {
11
+ const parsed = JSON.parse(args);
12
+ return typeof parsed.url === 'string' ? parsed.url : '(unknown)';
13
+ } catch {
14
+ return '(unknown)';
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Compact renderer for the `webfetch` tool — shows a one-line header with the
20
+ * fetched URL and elides the (often huge) response body so it doesn't fill
21
+ * the transcript. Mirrors `ReadOutput`'s minimal layout.
22
+ */
23
+ export function WebFetchOutput({ args, error }: WebFetchOutputProps) {
24
+ const url = parseUrl(args);
25
+ return (
26
+ <Box flexDirection="column" flexShrink={0} marginBottom={0}>
27
+ <ToolHeader name="webfetch" subtitle={url} error={error} />
28
+ </Box>
29
+ );
30
+ }
@@ -5,6 +5,7 @@ import { useTheme } from '../../context/ThemeContext';
5
5
  import { useSpinner } from '../../hooks/useUI';
6
6
  import { EditOutput } from './EditOutput';
7
7
  import { ReadOutput } from './ReadOutput';
8
+ import { WebFetchOutput } from './WebFetchOutput';
8
9
  import { WriteOutput } from './WriteOutput';
9
10
 
10
11
  /**
@@ -76,6 +77,8 @@ function renderToolOutput(
76
77
  return <WriteOutput args={args} content={content} error={error} />;
77
78
  case 'diff':
78
79
  return <EditOutput args={args} content={content} error={error} hint={hint} />;
80
+ case 'webfetch':
81
+ return <WebFetchOutput args={args} error={error} />;
79
82
  default:
80
83
  return <GenericToolOutput name={name} args={args} content={content} error={error} hint={hint} />;
81
84
  }
@@ -23,17 +23,37 @@ describe('BUILTIN_COMMANDS', () => {
23
23
  const onTogglePicker = mock(() => undefined);
24
24
  const onToggleSessionPicker = mock(() => undefined);
25
25
  const onNew = mock(() => undefined);
26
+ const onCompact = mock(() => undefined);
26
27
  const onShowContext = mock(() => undefined);
27
- const actions: InputActions = { onTogglePicker, onToggleSessionPicker, onNew, onShowContext };
28
+ const onUpdate = mock(() => undefined);
29
+ const actions: InputActions = {
30
+ onTogglePicker,
31
+ onToggleSessionPicker,
32
+ onNew,
33
+ onCompact,
34
+ onShowContext,
35
+ onUpdate,
36
+ };
28
37
 
29
38
  for (const cmd of BUILTIN_COMMANDS) {
30
- cmd.invoke?.(actions);
39
+ cmd.invoke?.(actions, '');
31
40
  }
32
41
 
33
42
  expect(onTogglePicker).toHaveBeenCalledTimes(1);
34
43
  expect(onToggleSessionPicker).toHaveBeenCalledTimes(1);
35
44
  expect(onNew).toHaveBeenCalledTimes(1);
45
+ expect(onCompact).toHaveBeenCalledTimes(1);
36
46
  expect(onShowContext).toHaveBeenCalledTimes(1);
47
+ expect(onUpdate).toHaveBeenCalledTimes(1);
48
+ });
49
+
50
+ it('/update forwards args to onUpdate', () => {
51
+ const onUpdate = mock(() => undefined);
52
+ const actions: InputActions = { onUpdate };
53
+ const cmd = BUILTIN_COMMANDS.find((c) => c.name === '/update');
54
+ expect(cmd).toBeDefined();
55
+ cmd?.invoke?.(actions, 'plugins');
56
+ expect(onUpdate).toHaveBeenCalledWith('plugins');
37
57
  });
38
58
  });
39
59
 
@@ -3,7 +3,8 @@ import type { InputActions } from './useInputHandler';
3
3
 
4
4
  /**
5
5
  * A slash command can either:
6
- * - run via `invoke(actions)` — for builtins that just toggle UI state, or
6
+ * - run via `invoke(actions, args)` — for builtins that just toggle UI state
7
+ * (most ignore `args`), or
7
8
  * - run via `execute(args)` — for plugin-supplied commands that produce
8
9
  * side-effects through the agent runtime.
9
10
  *
@@ -12,7 +13,7 @@ import type { InputActions } from './useInputHandler';
12
13
  export interface SlashCommand {
13
14
  name: string;
14
15
  description: string;
15
- invoke?: (actions: InputActions) => void;
16
+ invoke?: (actions: InputActions, args: string) => void;
16
17
  execute?: (args: string) => Promise<string | undefined>;
17
18
  }
18
19
 
@@ -30,6 +31,11 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [
30
31
  description: 'Show the LLM context (system prompt, messages, tools) as plain text',
31
32
  invoke: (a) => a.onShowContext?.(),
32
33
  },
34
+ {
35
+ name: '/update',
36
+ description: 'Update mu and installed npm plugins. Args: "plugins" | "self" (default: all)',
37
+ invoke: (a, args) => a.onUpdate?.(args),
38
+ },
33
39
  ];
34
40
 
35
41
  export function fromPluginCommand(command: PluginSlashCommand, context: CommandContext): SlashCommand {
@@ -23,7 +23,7 @@ export function useCommandExecutor(options: CommandExecutorOptions) {
23
23
  void command.execute(args);
24
24
  return;
25
25
  }
26
- command.invoke?.(actions);
26
+ command.invoke?.(actions, args);
27
27
  },
28
28
  [actions],
29
29
  );
@@ -1,6 +1,8 @@
1
1
  import type { InputInfoSegment } from 'mu-core';
2
2
  import { useCallback, useMemo, useRef } from 'react';
3
3
  import { useChatContext } from '../chat/ChatContext';
4
+ import type { InkUIService } from '../plugins/InkUIService';
5
+ import { runUpdateInTui, type UpdateScope } from '../update/runUpdateInTui';
4
6
  import { dumpContext } from './dumpContext';
5
7
  import type { InputBoxViewProps } from './InputBoxView';
6
8
  import { useCommandExecutor } from './useCommandExecutor';
@@ -8,6 +10,14 @@ import { type InputActions, type MentionMode, useInputHandler } from './useInput
8
10
  import { type MentionPickerState, useMentionPicker } from './useMentionPicker';
9
11
  import { usePluginShortcuts } from './usePluginShortcuts';
10
12
 
13
+ function parseUpdateScope(args: string): UpdateScope | null {
14
+ const trimmed = args.trim().toLowerCase();
15
+ if (trimmed === '' || trimmed === 'all') return 'all';
16
+ if (trimmed === 'plugins') return 'plugins';
17
+ if (trimmed === 'self' || trimmed === 'mu') return 'self';
18
+ return null;
19
+ }
20
+
11
21
  export interface InputBoxProps {
12
22
  onSubmit: (text: string) => void;
13
23
  onScrollUp?: () => void;
@@ -96,12 +106,13 @@ interface ActionDeps {
96
106
  models: ReturnType<typeof useChatContext>['models'];
97
107
  toggles: ReturnType<typeof useChatContext>['toggles'];
98
108
  onShowContext: () => Promise<void>;
109
+ onUpdate: (args: string) => void;
99
110
  onScrollUp?: () => void;
100
111
  onScrollDown?: () => void;
101
112
  }
102
113
 
103
114
  function useInputActions(deps: ActionDeps): InputActions {
104
- const { abort, attachment, session, models, toggles, onShowContext, onScrollUp, onScrollDown } = deps;
115
+ const { abort, attachment, session, models, toggles, onShowContext, onUpdate, onScrollUp, onScrollDown } = deps;
105
116
  return useMemo<InputActions>(
106
117
  () => ({
107
118
  onCtrlC: abort.onCtrlC,
@@ -115,6 +126,7 @@ function useInputActions(deps: ActionDeps): InputActions {
115
126
  onTogglePicker: toggles.onTogglePicker,
116
127
  onToggleSessionPicker: toggles.onToggleSessionPicker,
117
128
  onShowContext,
129
+ onUpdate,
118
130
  onScrollUp,
119
131
  onScrollDown,
120
132
  modelCount: models.models.length,
@@ -130,12 +142,29 @@ function useInputActions(deps: ActionDeps): InputActions {
130
142
  toggles.onTogglePicker,
131
143
  toggles.onToggleSessionPicker,
132
144
  onShowContext,
145
+ onUpdate,
133
146
  onScrollUp,
134
147
  onScrollDown,
135
148
  ],
136
149
  );
137
150
  }
138
151
 
152
+ function useOnUpdate(uiService: InkUIService | undefined) {
153
+ return useCallback(
154
+ (args: string) => {
155
+ if (!uiService) return;
156
+ const scope = parseUpdateScope(args);
157
+ if (!scope) {
158
+ uiService.notify('Usage: /update [plugins|self|all]', 'warning');
159
+ return;
160
+ }
161
+ uiService.notify(scope === 'all' ? 'Starting update…' : `Starting ${scope} update…`, 'info');
162
+ void runUpdateInTui(scope, uiService);
163
+ },
164
+ [uiService],
165
+ );
166
+ }
167
+
139
168
  const EMPTY_SEGMENTS: InputInfoSegment[] = [];
140
169
 
141
170
  export function useInputBox({
@@ -164,6 +193,8 @@ export function useInputBox({
164
193
  }
165
194
  }, [config, session.messages, registry, uiService]);
166
195
 
196
+ const onUpdate = useOnUpdate(uiService);
197
+
167
198
  const actions = useInputActions({
168
199
  abort,
169
200
  attachment,
@@ -171,6 +202,7 @@ export function useInputBox({
171
202
  models,
172
203
  toggles,
173
204
  onShowContext,
205
+ onUpdate,
174
206
  onScrollUp,
175
207
  onScrollDown,
176
208
  });
@@ -36,6 +36,7 @@ export interface InputActions {
36
36
  onEsc?: () => void;
37
37
  onScrollUp?: () => void;
38
38
  onScrollDown?: () => void;
39
+ onUpdate?: (args: string) => void;
39
40
  modelCount?: number;
40
41
  }
41
42
 
@@ -0,0 +1,127 @@
1
+ /**
2
+ * TUI-friendly update runner — same semantics as `mu update` from the CLI,
3
+ * but never writes to stdout/stderr (Ink owns the terminal). Progress and
4
+ * results are surfaced as toasts via `uiService.notify`.
5
+ *
6
+ * Mirrors `cli/update.ts` but uses `child_process.execFile` with `stdio:
7
+ * 'pipe'` so subprocess output is buffered, not streamed to the TUI's tty.
8
+ */
9
+
10
+ import { execFile } from 'node:child_process';
11
+ import { realpathSync } from 'node:fs';
12
+ import { promisify } from 'node:util';
13
+ import { ensureDataDir } from '../../cli/install';
14
+ import { invalidateUpdateCheckCache } from '../../runtime/startupUpdateCheck';
15
+ import { listConfiguredNpmPlugins, PACKAGE_NAME } from '../../runtime/updateCheck';
16
+ import type { InkUIService } from '../plugins/InkUIService';
17
+
18
+ const execFileAsync = promisify(execFile);
19
+
20
+ export type UpdateScope = 'all' | 'plugins' | 'self';
21
+
22
+ interface SelfInstallStrategy {
23
+ manager: 'bun' | 'npm' | 'pnpm' | 'yarn' | 'unknown';
24
+ command?: [string, string[]];
25
+ }
26
+
27
+ function detectSelfInstall(): SelfInstallStrategy {
28
+ let bin = process.argv[1] ?? '';
29
+ try {
30
+ bin = realpathSync(bin);
31
+ } catch {
32
+ // keep raw argv
33
+ }
34
+ const norm = bin.replace(/\\/g, '/');
35
+ if (norm.includes('/.bun/') || norm.includes('/bun/install/')) {
36
+ return { manager: 'bun', command: ['bun', ['add', '-g', `${PACKAGE_NAME}@latest`]] };
37
+ }
38
+ if (norm.includes('/pnpm/')) {
39
+ return { manager: 'pnpm', command: ['pnpm', ['add', '-g', `${PACKAGE_NAME}@latest`]] };
40
+ }
41
+ if (norm.includes('/.yarn/') || norm.includes('/yarn/global/')) {
42
+ return { manager: 'yarn', command: ['yarn', ['global', 'add', `${PACKAGE_NAME}@latest`]] };
43
+ }
44
+ if (norm.includes('/npm/') || norm.includes('node_modules/.bin/mu')) {
45
+ return { manager: 'npm', command: ['npm', ['i', '-g', `${PACKAGE_NAME}@latest`]] };
46
+ }
47
+ return { manager: 'unknown' };
48
+ }
49
+
50
+ async function updatePlugin(name: string, dataDir: string): Promise<boolean> {
51
+ try {
52
+ await execFileAsync('bun', ['update', '--latest', name], { cwd: dataDir });
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ async function updatePlugins(ui: InkUIService): Promise<{ ok: number; failed: number; total: number }> {
60
+ const dataDir = ensureDataDir();
61
+ const names = listConfiguredNpmPlugins();
62
+ if (names.length === 0) return { ok: 0, failed: 0, total: 0 };
63
+
64
+ let ok = 0;
65
+ let failed = 0;
66
+ for (const name of names) {
67
+ ui.notify(`Updating ${name}…`, 'info');
68
+ if (await updatePlugin(name, dataDir)) ok += 1;
69
+ else {
70
+ failed += 1;
71
+ ui.notify(`Failed to update ${name}`, 'error');
72
+ }
73
+ }
74
+ return { ok, failed, total: names.length };
75
+ }
76
+
77
+ async function updateSelf(ui: InkUIService): Promise<boolean> {
78
+ const strategy = detectSelfInstall();
79
+ if (!strategy.command) {
80
+ ui.notify(`Cannot auto-detect mu's installer. Re-install manually: bun add -g ${PACKAGE_NAME}@latest`, 'warning');
81
+ return false;
82
+ }
83
+ const [bin, args] = strategy.command;
84
+ ui.notify(`Updating mu via ${strategy.manager}…`, 'info');
85
+ try {
86
+ await execFileAsync(bin, args);
87
+ ui.notify('mu updated. Restart your session to pick up the new version.', 'success');
88
+ return true;
89
+ } catch (err) {
90
+ const message = err instanceof Error ? err.message.split('\n')[0] : String(err);
91
+ ui.notify(`Failed to update mu: ${message}`, 'error');
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Entry point used by the `/update` slash command. Fire-and-forget — the
98
+ * caller voids the returned promise. All progress / errors land in toasts.
99
+ */
100
+ export async function runUpdateInTui(scope: UpdateScope, ui: InkUIService): Promise<void> {
101
+ if (scope === 'plugins') {
102
+ const { ok, failed, total } = await updatePlugins(ui);
103
+ if (total === 0) ui.notify('No npm plugins configured.', 'info');
104
+ else if (failed === 0) ui.notify(`Updated ${ok}/${total} plugin${total === 1 ? '' : 's'}.`, 'success');
105
+ else ui.notify(`Plugins: ${ok} updated, ${failed} failed.`, 'warning');
106
+ invalidateUpdateCheckCache();
107
+ return;
108
+ }
109
+
110
+ if (scope === 'self') {
111
+ await updateSelf(ui);
112
+ invalidateUpdateCheckCache();
113
+ return;
114
+ }
115
+
116
+ // 'all'
117
+ const plugins = await updatePlugins(ui);
118
+ const selfOk = await updateSelf(ui);
119
+ if (plugins.total === 0 && selfOk) {
120
+ // mu-only success already toasted; nothing to add
121
+ } else if (plugins.failed === 0 && selfOk) {
122
+ ui.notify('Update complete.', 'success');
123
+ } else {
124
+ ui.notify('Update finished with errors — see prior messages.', 'warning');
125
+ }
126
+ invalidateUpdateCheckCache();
127
+ }