martty 0.2.28 → 0.2.30

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/lib/acp-client.js CHANGED
@@ -80,12 +80,31 @@ export function apply(ctx, config = {}) {
80
80
  provide(ctx, liveAgent)
81
81
  return
82
82
  }
83
+ // Replacing the agent: the previous child must not outlive its spec.
84
+ if (liveAgent !== null && liveAgent.child.exitCode === null) {
85
+ try {
86
+ liveAgent.child.kill('SIGTERM')
87
+ } catch {
88
+ // already gone
89
+ }
90
+ }
91
+ liveAgent = null
83
92
  const child = spawn(agent.command, agent.args ?? [], {
84
93
  stdio: ['pipe', 'pipe', 'inherit'],
85
94
  env: { ...process.env, ...(agent.env ?? {}) },
86
95
  })
87
96
  child.stdin.on('error', () => {})
88
97
  child.stdout.on('error', () => {})
98
+ child.on('error', (err) => {
99
+ // A failed spawn (ENOENT, EACCES) emits 'error' on the child; without a
100
+ // listener it is an uncaught exception. Surface EOF to the transport
101
+ // instead so pending requests fail instead of hanging, and drop the
102
+ // cached handle so the next apply() retries the spawn.
103
+ console.error(`acp-client: failed to spawn agent ${agent.command}: ${err.message}`)
104
+ if (liveAgent?.child === child) liveAgent = null
105
+ child.stdin.destroy()
106
+ child.stdout.destroy()
107
+ })
89
108
  const service = {
90
109
  kind: 'spawn',
91
110
  command: agent.command,
@@ -25,6 +25,7 @@ function zero(sessionId) {
25
25
  usage: {
26
26
  input: 0, output: 0, cached: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0,
27
27
  },
28
+ context: { used: 0, size: 0 },
28
29
  stats: {
29
30
  turns: 0,
30
31
  steps: 0,
@@ -137,6 +138,19 @@ export function installAcpSessionStats(ctx, options = {}) {
137
138
  }
138
139
 
139
140
  const type = readString(update, 'sessionUpdate', 'session_update')
141
+ if (type === 'usage_update') {
142
+ // Harness-authoritative context gauge: `used` is the final prompt
143
+ // size (input + cache + output) of the last step and `size` the
144
+ // real model window from `request/context`. Client-side
145
+ // accumulation cannot derive either: per-prompt input sums every
146
+ // step's full context, and standard ACP does not carry the window.
147
+ const size = number(update.size)
148
+ if (size > 0) {
149
+ value.context = { used: number(update.used), size }
150
+ publish()
151
+ }
152
+ return
153
+ }
140
154
  const prompt = sessionId === undefined ? undefined : activePrompts.get(sessionId)
141
155
  if (prompt !== undefined && prompt.firstToken === undefined
142
156
  && (type === 'agent_message_chunk' || type === 'agent_thought_chunk')
@@ -5,29 +5,49 @@ export const inject = ['tuiAgents', 'tuiSlots', 'tuiCommands']
5
5
 
6
6
  export function apply(ctx) {
7
7
  let current = ctx.tuiAgents.current()
8
+ // Issue #80: `/agents` is an on/off switch for the panel. `visible` is
9
+ // the user preference; `forced` remembers `/agents on` so the summary
10
+ // stays open even after every task ended, until the next batch starts.
11
+ let visible = true
12
+ let forced = false
8
13
  let panel
9
14
 
10
15
  const stopSlot = ctx.tuiSlots.inject('conversation.navigation.dock', () => {
11
16
  panel = ctx.tuiSlots.register(
12
17
  { name: 'conversation.navigation.dock', id: 'agents-view', order: 0 },
13
- dockNodes(current),
18
+ dockNodes(current, visible, forced),
14
19
  )
15
20
  return () => panel.dispose()
16
21
  })
17
22
  const stopAgents = ctx.tuiAgents.subscribe((snapshot) => {
18
23
  current = snapshot
19
- panel?.update(dockNodes(current))
24
+ // A new running batch clears the forced-open state so the panel
25
+ // auto-closes again once this batch ends.
26
+ if (snapshot.items.some((item) => item.kind === 'subagent' && item.status === 'running')) {
27
+ forced = false
28
+ }
29
+ panel?.update(dockNodes(current, visible, forced))
20
30
  })
21
31
  const stopCommand = ctx.tuiCommands.register({
22
32
  name: 'agents',
23
- description: 'Switch the visible Agent transcript',
33
+ description: 'Toggle the Agent panel (on/off)',
34
+ input: { hint: '[on|off|agent-id]' },
24
35
  }, async (args) => {
25
- current = ctx.tuiAgents.current()
26
- const target = args.trim()
27
- if (target.length > 0) {
28
- return ctx.tuiAgents.select(target)
36
+ const arg = args.trim().toLowerCase()
37
+ if (arg === 'on') {
38
+ visible = true
39
+ forced = true
40
+ } else if (arg === 'off') {
41
+ visible = false
42
+ forced = false
43
+ } else if (arg === '') {
44
+ visible = !visible
45
+ forced = visible
46
+ } else {
47
+ return ctx.tuiAgents.select(args.trim())
29
48
  }
30
- return ctx.tuiAgents.navigate('begin')
49
+ panel?.update(dockNodes(current, visible, forced))
50
+ return true
31
51
  })
32
52
 
33
53
  return () => {
@@ -37,9 +57,10 @@ export function apply(ctx) {
37
57
  }
38
58
  }
39
59
 
40
- function dockNodes(snapshot) {
60
+ function dockNodes(snapshot, visible = true, forced = false) {
41
61
  if (!Array.isArray(snapshot?.items) || snapshot.items.length < 2) return []
42
62
  const selecting = snapshot.selectedId !== null && snapshot.selectedId !== undefined
63
+ if (!visible && !selecting) return []
43
64
  if (!selecting) {
44
65
  const agents = snapshot.items.filter((item) => item.kind === 'subagent')
45
66
  const marked = agents.filter((item) => item.current !== false)
@@ -47,6 +68,10 @@ function dockNodes(snapshot) {
47
68
  const completed = current.filter((item) => item.status === 'finished' || item.status === 'failed').length
48
69
  const running = current.some((item) => item.status === 'running')
49
70
  const failed = current.some((item) => item.status === 'failed')
71
+ // Issue #80: auto-close once every Agent task has ended. A failed task
72
+ // keeps the summary so the failure stays visible; `/agents on` (forced)
73
+ // holds it open until the next batch starts running.
74
+ if (!running && !failed && !forced) return []
50
75
  return [
51
76
  {
52
77
  id: 'summary', kind: 'generic', title: '· Agents', body: `${completed}/${current.length}`,
package/lib/boot.js CHANGED
@@ -1,3 +1,24 @@
1
+ /**
2
+ * Boot-time restore of a statically-registered gallery palette (ayu,
3
+ * iceberg, …). `/theme` persistence writes `settings.theme`; dynamic
4
+ * packs are restored by tui-local-plugins (they have an owner), but the
5
+ * builtin gallery registers with no owner, so nothing would re-activate
6
+ * the saved pick on the next launch. Runs after every static theme
7
+ * Plugin is registered; `activate` notifies the painter (or queues until
8
+ * `bindNotify` flushes). A broken or unknown id must not block boot.
9
+ */
10
+ export function restorePreferredTheme(tuiTheme) {
11
+ const preferred = tuiTheme?.preferred?.()
12
+ if (typeof preferred !== 'string' || preferred === 'default') return
13
+ if (tuiTheme?.owner?.(preferred) !== undefined) return
14
+ if (!tuiTheme?.isLoaded?.(preferred)) return
15
+ try {
16
+ tuiTheme.activate(preferred)
17
+ } catch {
18
+ // a broken pack must not block TUI startup
19
+ }
20
+ }
21
+
1
22
  /**
2
23
  * Cordis client boot: tuiTheme + tuiSlots + acp-client + TUI shell.
3
24
  *
@@ -21,6 +42,8 @@ import { apply as applyKanagawa, inject as kanagawaInject } from './kanagawa.js'
21
42
  import { apply as applyEverforest, inject as everforestInject } from './everforest.js'
22
43
  import { apply as applyIceberg, inject as icebergInject } from './iceberg.js'
23
44
  import { apply as applySolarized, inject as solarizedInject } from './solarized.js'
45
+ import { apply as applyOne, inject as oneInject } from './one.js'
46
+ import { apply as applyTomorrow, inject as tomorrowInject } from './tomorrow.js'
24
47
  import { apply as applyCommands } from './tui-commands.js'
25
48
  import { apply as applyOverlay } from './tui-overlay.js'
26
49
  import { apply as applyAgents } from './tui-agents.js'
@@ -65,6 +88,9 @@ export async function bootClient(options = {}) {
65
88
  await ctx.plugin({ name: 'tui-theme-everforest', inject: everforestInject, apply: applyEverforest })
66
89
  await ctx.plugin({ name: 'tui-theme-iceberg', inject: icebergInject, apply: applyIceberg })
67
90
  await ctx.plugin({ name: 'tui-theme-solarized', inject: solarizedInject, apply: applySolarized })
91
+ await ctx.plugin({ name: 'tui-theme-one', inject: oneInject, apply: applyOne })
92
+ await ctx.plugin({ name: 'tui-theme-tomorrow', inject: tomorrowInject, apply: applyTomorrow })
93
+ restorePreferredTheme(ctx.get('tuiTheme'))
68
94
  await ctx.plugin({ name: 'tui-slots', inject: [], apply: applySlots })
69
95
  await ctx.plugin({ name: 'tui-commands', inject: [], apply: applyCommands })
70
96
  await ctx.plugin({ name: 'tui-overlay', inject: [], apply: applyOverlay })
@@ -122,6 +148,9 @@ export async function bootClient(options = {}) {
122
148
  applyEverforest(ctx)
123
149
  applyIceberg(ctx)
124
150
  applySolarized(ctx)
151
+ applyOne(ctx)
152
+ applyTomorrow(ctx)
153
+ restorePreferredTheme(ctx.tuiTheme)
125
154
  applySlots(ctx)
126
155
  applyCommands(ctx)
127
156
  applyOverlay(ctx)
package/lib/one.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Gallery palette pack `one`. Registers complete dark/light token maps:
3
+ * dark from One Dark, light from One Light (terminalcolors.com/themes/one). Does not activate:
4
+ * `/theme` covers it. `inject = ['tuiTheme']`: sibling profile row, not
5
+ * `ctx.plugin` inside the runner.
6
+ */
7
+
8
+ import { readFileSync } from 'node:fs'
9
+
10
+ const onePalette = JSON.parse(
11
+ readFileSync(new URL('./palettes/one.json', import.meta.url), 'utf8'),
12
+ )
13
+
14
+ export const name = 'tui-theme-one'
15
+ export const inject = ['tuiTheme']
16
+
17
+ export function apply(ctx) {
18
+ ctx.effect(() => ctx.tuiTheme.register(onePalette, { activate: false }))
19
+ }
20
+
21
+ export { onePalette }
@@ -0,0 +1,44 @@
1
+ {
2
+ "id": "one",
3
+ "label": "One",
4
+ "dark": {
5
+ "bg": "#282c34",
6
+ "surface": "#282c34",
7
+ "panel": "#1e2127",
8
+ "fg": "#abb2bf",
9
+ "fg_secondary": "#5c6370",
10
+ "fg_tertiary": "#5c6370",
11
+ "caption": "#5c6370",
12
+ "brand": "#61afef",
13
+ "brand_soft": "#c678dd",
14
+ "bubble_bg": "#abb2bf",
15
+ "bubble_fg": "#282c34",
16
+ "border": "#5c6370",
17
+ "code_bg": "#282c34",
18
+ "ok": "#98c379",
19
+ "warn": "#d19a66",
20
+ "err": "#e06c75",
21
+ "hint": "#56b6c2",
22
+ "chip_bg": "#1e2127"
23
+ },
24
+ "light": {
25
+ "bg": "#f8f8f8",
26
+ "surface": "#f8f8f8",
27
+ "panel": "#bbbbbb",
28
+ "fg": "#2a2b33",
29
+ "fg_secondary": "#5c6370",
30
+ "fg_tertiary": "#5c6370",
31
+ "caption": "#5c6370",
32
+ "brand": "#2f5af3",
33
+ "brand_soft": "#a00095",
34
+ "bubble_bg": "#2a2b33",
35
+ "bubble_fg": "#f8f8f8",
36
+ "border": "#5c6370",
37
+ "code_bg": "#f8f8f8",
38
+ "ok": "#3e953a",
39
+ "warn": "#c18401",
40
+ "err": "#de3d35",
41
+ "hint": "#0184bc",
42
+ "chip_bg": "#bbbbbb"
43
+ }
44
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "id": "tomorrow",
3
+ "label": "Tomorrow",
4
+ "dark": {
5
+ "bg": "#000000",
6
+ "surface": "#000000",
7
+ "panel": "#424242",
8
+ "fg": "#eaeaea",
9
+ "fg_secondary": "#969896",
10
+ "fg_tertiary": "#969896",
11
+ "caption": "#969896",
12
+ "brand": "#7aa6da",
13
+ "brand_soft": "#c397d8",
14
+ "bubble_bg": "#424242",
15
+ "bubble_fg": "#eaeaea",
16
+ "border": "#969896",
17
+ "code_bg": "#000000",
18
+ "ok": "#b9ca4a",
19
+ "warn": "#e7c547",
20
+ "err": "#d54e53",
21
+ "hint": "#70c0b1",
22
+ "chip_bg": "#424242"
23
+ },
24
+ "light": {
25
+ "bg": "#ffffff",
26
+ "surface": "#ffffff",
27
+ "panel": "#d6d6d6",
28
+ "fg": "#4d4d4c",
29
+ "fg_secondary": "#8e908c",
30
+ "fg_tertiary": "#8e908c",
31
+ "caption": "#8e908c",
32
+ "brand": "#4271ae",
33
+ "brand_soft": "#8959a8",
34
+ "bubble_bg": "#d6d6d6",
35
+ "bubble_fg": "#4d4d4c",
36
+ "border": "#8e908c",
37
+ "code_bg": "#ffffff",
38
+ "ok": "#718c00",
39
+ "warn": "#eab700",
40
+ "err": "#c82829",
41
+ "hint": "#3e999f",
42
+ "chip_bg": "#d6d6d6"
43
+ }
44
+ }
package/lib/plan-view.js CHANGED
@@ -25,7 +25,7 @@ export function apply(ctx) {
25
25
  current = ctx.acpSessionPlan.current()
26
26
  ctx.tuiOverlay.openView({
27
27
  id: 'plan-view',
28
- title: 'Plan',
28
+ title: viewTitle(current),
29
29
  nodes: viewNodes(current),
30
30
  })
31
31
  })
@@ -67,6 +67,18 @@ function dockNodes(plan) {
67
67
  }]
68
68
  }
69
69
 
70
+ // The overlay window title: the `n/m` counter lives in the border, not in
71
+ // the body, so the review never repeats its heading (issue #85).
72
+ function viewTitle(plan) {
73
+ if (plan !== null && plan.kind === 'items') {
74
+ const total = plan.entries.length
75
+ if (total === 0) return 'Plan'
76
+ const completed = plan.entries.filter((entry) => entry.status === 'completed').length
77
+ return `Plan ${completed}/${total}`
78
+ }
79
+ return 'Plan'
80
+ }
81
+
70
82
  function viewNodes(plan) {
71
83
  if (plan === null) {
72
84
  return [{ id: 'empty', kind: 'notice', level: 'info', text: 'No active plan' }]
@@ -78,10 +90,8 @@ function viewNodes(plan) {
78
90
  return [{ id: 'file', kind: 'notice', level: 'info', text: plan.uri }]
79
91
  }
80
92
  // Items render as one markdown node: a task-list review the transcript
81
- // pipeline can fully render (headings, bold, strikethrough).
82
- const total = plan.entries.length
83
- const completed = plan.entries.filter((entry) => entry.status === 'completed').length
84
- const lines = [`## Plan · ${completed}/${total}`, '']
93
+ // pipeline can fully render (bold, strikethrough).
94
+ const lines = []
85
95
  for (const entry of plan.entries) {
86
96
  const content = entry.content.replace(/\s*\n\s*/g, ' ')
87
97
  let item
package/lib/stats-view.js CHANGED
@@ -1,47 +1,38 @@
1
1
  /** Built-in Client Plugin: standard ACP usage and timing in the composer dock. */
2
2
 
3
3
  export const name = 'stats-view'
4
- export const inject = ['acpSessionStats', 'acpSessionStatus', 'tuiSlots']
4
+ export const inject = ['acpSessionStats', 'tuiSlots']
5
5
 
6
6
  export function apply(ctx) {
7
7
  let current = ctx.acpSessionStats.current()
8
- let status = ctx.acpSessionStatus.current()
9
8
  let panel
10
9
  const stopSlot = ctx.tuiSlots.inject('conversation.composer.dock', () => {
11
10
  panel = ctx.tuiSlots.register(
12
11
  { name: 'conversation.composer.dock', id: 'stats' },
13
- nodesOf(current, status),
12
+ nodesOf(current),
14
13
  )
15
14
  return () => panel.dispose()
16
15
  })
17
16
  const stopStats = ctx.acpSessionStats.subscribe((snapshot) => {
18
17
  current = snapshot
19
- panel?.update(nodesOf(current, status))
20
- })
21
- const stopStatus = ctx.acpSessionStatus.subscribe((snapshot) => {
22
- status = snapshot
23
- panel?.update(nodesOf(current, status))
18
+ panel?.update(nodesOf(current))
24
19
  })
25
20
  return () => {
26
21
  stopStats?.()
27
- stopStatus?.()
28
22
  stopSlot?.()
29
23
  }
30
24
  }
31
25
 
32
- function nodesOf(snapshot, status) {
26
+ function nodesOf(snapshot) {
33
27
  const usage = snapshot?.usage ?? {}
34
28
  const stats = snapshot?.stats ?? {}
35
- const model = status?.model
36
29
  const nodes = []
37
30
  if ((usage.input ?? 0) > 0 || (usage.output ?? 0) > 0) {
38
31
  nodes.push(node('tokens', `↑${formatTokens(usage.input)} · ↓${formatTokens(usage.output)}`))
39
- const used = (usage.input ?? 0) + (usage.output ?? 0)
40
- + (usage.cached ?? 0) + (usage.reasoning ?? 0)
41
- const size = contextWindowOf(model)
42
- if (used > 0 && size > 0) {
43
- const pct = Math.min(100, used / size * 100).toFixed(1)
44
- nodes.push(node('context', `${pct}%/${contextSizeLabel(size)}`))
32
+ const gauge = contextGauge(snapshot)
33
+ if (gauge.used > 0 && gauge.size > 0) {
34
+ const pct = Math.min(100, gauge.used / gauge.size * 100).toFixed(1)
35
+ nodes.push(node('context', `${pct}%/${contextSizeLabel(gauge.size)}`))
45
36
  }
46
37
  }
47
38
  if ((stats.turns ?? 0) > 0 || (stats.steps ?? 0) > 0) {
@@ -83,18 +74,19 @@ function node(id, title) {
83
74
  function plural(value, singular) { return value === 1 ? singular : `${singular}s` }
84
75
 
85
76
  /**
86
- * Context window of the current model (tokens). The ACP surface does not
87
- * carry the window size, so the known DeepSeek family sizes stand in;
88
- * unknown models fall back to a common 128K.
77
+ * Context-window gauge. Only the harness `usage_update` readout is shown:
78
+ * `used` is the final prompt size and `size` the real model window from
79
+ * `request/context`. Nothing client-side can reproduce either per-turn
80
+ * usage sums every step's full context (multi-step turns over-report),
81
+ * and a model-name guess does not know the window (the old 128K guess is
82
+ * what pegged the dock at 100% early in issue #77). Without an
83
+ * authoritative readout the gauge is hidden instead of showing a wrong
84
+ * percentage.
89
85
  */
90
- function contextWindowOf(model) {
91
- switch (model) {
92
- case 'deepseek-v4-flash':
93
- case 'deepseek-v4-pro':
94
- return 1_000_000
95
- default:
96
- return 128_000
97
- }
86
+ function contextGauge(snapshot) {
87
+ const context = snapshot?.context
88
+ if (context?.size > 0) return { used: context.used ?? 0, size: context.size }
89
+ return { used: 0, size: 0 }
98
90
  }
99
91
 
100
92
  /** `1000000` → `1.0M`, `128000` → `128K` — keeps the one-decimal look of
@@ -27,7 +27,8 @@ export function apply(ctx) {
27
27
  }
28
28
 
29
29
  /**
30
- * One `## status` markdown node. Run-state facts come from
30
+ * One markdown node of run-state facts (the window border already names
31
+ * the popup, so no in-body heading). Facts come from
31
32
  * `acpSessionStatus`; every token/turn/step/timing fact comes from
32
33
  * `acpSessionStats.current()` — the same snapshot `stats-view` renders in
33
34
  * the composer dock, so the two readouts can never drift apart.
@@ -37,7 +38,7 @@ function statusMarkdown(status, stats) {
37
38
  const folded = stats?.stats ?? {}
38
39
  const total = (usage.input ?? 0) + (usage.output ?? 0)
39
40
  + (usage.cached ?? 0) + (usage.reasoning ?? 0)
40
- const lines = ['## status', '']
41
+ const lines = []
41
42
  lines.push(`- state · ${status.state ?? 'idle'}`)
42
43
  lines.push(`- acp · ${status.connection ?? 'not attached'}`)
43
44
  if (status.auth?.status !== undefined) {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Gallery palette pack `tomorrow`. Registers complete dark/light token maps:
3
+ * dark from Tomorrow Night Bright, light from Tomorrow
4
+ * (terminalcolors.com/themes/tomorrow). Does not activate:
5
+ * `/theme` covers it. `inject = ['tuiTheme']`: sibling profile row, not
6
+ * `ctx.plugin` inside the runner.
7
+ */
8
+
9
+ import { readFileSync } from 'node:fs'
10
+
11
+ const tomorrowPalette = JSON.parse(
12
+ readFileSync(new URL('./palettes/tomorrow.json', import.meta.url), 'utf8'),
13
+ )
14
+
15
+ export const name = 'tui-theme-tomorrow'
16
+ export const inject = ['tuiTheme']
17
+
18
+ export function apply(ctx) {
19
+ ctx.effect(() => ctx.tuiTheme.register(tomorrowPalette, { activate: false }))
20
+ }
21
+
22
+ export { tomorrowPalette }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "martty",
3
- "version": "0.2.28",
3
+ "version": "0.2.30",
4
4
  "description": "Terminal-native ACP client UI; Cordis client tree, any ACP agent",
5
5
  "license": "MIT",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file