martty 0.2.13 → 0.2.15-beta.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 CHANGED
@@ -85,6 +85,8 @@ dsh-tui --demo-skin
85
85
  platform-native modifier bindings, mouse selection, and inline-expanded tools.
86
86
  - Dark/light themes, clipboard routing for local, tmux, and SSH sessions, plus
87
87
  the optional `/liang` pixel companion.
88
+ - A built-in `/deepseeklogo` Client Plugin that opens the classic DeepSeek
89
+ Harness whale as a semantic overlay without writing to the transcript.
88
90
  - A root `chrome.right` plugin rail for validated TuiNode trees, with live
89
91
  update/unload and Client inspect support for Creator-authored plugins.
90
92
  - Lifecycle-owned local commands and native slider overlays, plus transactions
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Standard-ACP-backed run-state projection for the Client tree.
3
+ *
4
+ * Folds the non-statistics facts `/status` needs — connection, server,
5
+ * authenticate state, session binding, model, effort, permission, plan,
6
+ * agent preset, and the running/idle state — from messages the mux already
7
+ * observes. Statistics stay in `acpSessionStats`; this service never
8
+ * counts tokens or timings, so there is exactly one stats source.
9
+ */
10
+
11
+ import { Service } from '@deepseek-ai/cordis'
12
+
13
+ export const name = 'acp-session-status'
14
+ export const inject = ['acpClientEvents', 'acpSessionConfig']
15
+
16
+ const AUTH_REQUIRED_CODE = -32000
17
+ const SETUP_METHODS = new Set(['session/new', 'session/load'])
18
+ const RUNNING_UPDATE_TYPES = new Set([
19
+ 'agent_message_chunk',
20
+ 'agent_thought_chunk',
21
+ 'tool_call',
22
+ ])
23
+
24
+ class AcpSessionStatusService extends Service {
25
+ constructor(ctx, core) {
26
+ super(ctx, 'acpSessionStatus')
27
+ this.core = core
28
+ }
29
+
30
+ current() { return this.core.current() }
31
+ subscribe(listener) { return this.core.subscribe(this.ctx, listener) }
32
+ observeClient(message) { return this.core.observeClient(message) }
33
+ observeAgent(message) { return this.core.observeAgent(message) }
34
+ }
35
+
36
+ function zero() {
37
+ return {
38
+ state: 'idle',
39
+ connection: 'connecting',
40
+ server: undefined,
41
+ auth: { status: undefined, method: undefined },
42
+ session: { sessionId: undefined, bound: false },
43
+ model: undefined,
44
+ effort: undefined,
45
+ permission: undefined,
46
+ plan: undefined,
47
+ agent: undefined,
48
+ }
49
+ }
50
+
51
+ export function installAcpSessionStatus(ctx, options = {}) {
52
+ const events = options.events ?? ctx.acpClientEvents ?? ctx.get?.('acpClientEvents')
53
+ const sessionConfig = options.sessionConfig
54
+ ?? ctx.acpSessionConfig ?? ctx.get?.('acpSessionConfig')
55
+
56
+ const listeners = new Set()
57
+ let value = zero()
58
+ let initializeId
59
+ let pendingAuthenticate = new Set()
60
+ const pendingSetup = new Map()
61
+ let permissionPreset
62
+ let sandboxMode
63
+
64
+ function current() { return structuredClone(value) }
65
+
66
+ function publish() {
67
+ const snapshot = current()
68
+ for (const listener of [...listeners]) listener(snapshot)
69
+ }
70
+
71
+ function subscribe(effectCtx, listener) {
72
+ if (typeof listener !== 'function') {
73
+ throw new Error('acpSessionStatus.subscribe: listener must be a function')
74
+ }
75
+ const setup = () => {
76
+ listeners.add(listener)
77
+ return () => listeners.delete(listener)
78
+ }
79
+ const release = typeof effectCtx?.effect === 'function'
80
+ ? effectCtx.effect(setup, 'acpSessionStatus.subscribe')
81
+ : setup()
82
+ let disposed = false
83
+ return () => {
84
+ if (disposed) return
85
+ disposed = true
86
+ return release?.()
87
+ }
88
+ }
89
+
90
+ function observeClient(message) {
91
+ if (!object(message) || typeof message.method !== 'string') return
92
+ if (message.method === 'initialize' && message.id !== undefined) {
93
+ initializeId = message.id
94
+ return
95
+ }
96
+ if (message.method === 'authenticate' && message.id !== undefined) {
97
+ pendingAuthenticate.add(message.id)
98
+ value.auth.status = 'signing in'
99
+ publish()
100
+ return
101
+ }
102
+ if (SETUP_METHODS.has(message.method) && message.id !== undefined) {
103
+ pendingSetup.set(
104
+ message.id,
105
+ message.method === 'session/load'
106
+ ? readString(message.params, 'sessionId', 'session_id')
107
+ : undefined,
108
+ )
109
+ return
110
+ }
111
+ if (message.method === 'session/prompt' && value.state === 'idle') {
112
+ value.state = 'starting'
113
+ publish()
114
+ }
115
+ }
116
+
117
+ function observeAgent(message) {
118
+ if (!object(message)) return
119
+ if (message.id !== undefined && message.id === initializeId) {
120
+ initializeId = undefined
121
+ value.connection = 'attached'
122
+ const result = object(message.result) ? message.result : undefined
123
+ value.server = readString(result?.agentInfo, 'name')
124
+ const methods = Array.isArray(result?.authMethods) ? result.authMethods : []
125
+ const method = methods.find((candidate) => object(candidate)
126
+ && typeof candidate.id === 'string' && candidate.id.length > 0)
127
+ if (method !== undefined && value.auth.status === undefined) {
128
+ value.auth.method = readString(method, 'name', 'label') ?? method.id
129
+ }
130
+ publish()
131
+ return
132
+ }
133
+ if (message.id !== undefined && pendingAuthenticate.has(message.id)) {
134
+ pendingAuthenticate.delete(message.id)
135
+ if (message.error === undefined) {
136
+ value.auth.status = 'configured'
137
+ } else if (isAuthRequired(message.error)) {
138
+ value.auth.status = 'needs sign-in'
139
+ } else {
140
+ value.auth.status = undefined
141
+ }
142
+ publish()
143
+ return
144
+ }
145
+ if (message.id !== undefined && pendingSetup.has(message.id)) {
146
+ const requested = pendingSetup.get(message.id)
147
+ pendingSetup.delete(message.id)
148
+ if (message.error !== undefined) {
149
+ if (isAuthRequired(message.error)) value.auth.status = 'needs sign-in'
150
+ value.session = { sessionId: requested, bound: false }
151
+ } else {
152
+ const sessionId = readString(message.result, 'sessionId', 'session_id') ?? requested
153
+ value.session = { sessionId, bound: sessionId !== undefined }
154
+ }
155
+ publish()
156
+ return
157
+ }
158
+ if (message.error !== undefined && isAuthRequired(message.error)) {
159
+ value.auth.status = 'needs sign-in'
160
+ publish()
161
+ return
162
+ }
163
+
164
+ if (message.method === 'session.status' && object(message.params)) {
165
+ const status = readString(message.params, 'status')
166
+ if (status === 'running' || status === 'idle') {
167
+ value.state = status
168
+ publish()
169
+ }
170
+ return
171
+ }
172
+ if (message.method === 'session.event' && object(message.params)) {
173
+ const event = object(message.params.event) ? message.params.event : undefined
174
+ const type = readString(event, 'type')
175
+ const data = object(event?.data) ? event.data : undefined
176
+ if (type === 'permission/preset') {
177
+ const preset = readString(data, 'preset')
178
+ if (preset !== undefined) {
179
+ permissionPreset = preset
180
+ value.permission = permissionPreset ?? sandboxMode
181
+ publish()
182
+ }
183
+ } else if (type === 'sandbox/mode') {
184
+ const mode = readString(data, 'mode')
185
+ if (mode !== undefined) {
186
+ sandboxMode = mode
187
+ value.permission = permissionPreset ?? sandboxMode
188
+ publish()
189
+ }
190
+ } else if (type === 'plan/mode' && typeof data?.active === 'boolean') {
191
+ value.plan = data.active
192
+ publish()
193
+ } else if (type === 'agent-preset/selected') {
194
+ const preset = readString(data, 'agentPreset')
195
+ if (preset !== undefined) {
196
+ value.agent = preset
197
+ publish()
198
+ }
199
+ }
200
+ return
201
+ }
202
+ if (message.method !== 'session/update' || !object(message.params)) return
203
+ const update = object(message.params.update) ? message.params.update : undefined
204
+ const type = readString(update, 'sessionUpdate', 'session_update')
205
+ if (value.state === 'starting' && RUNNING_UPDATE_TYPES.has(type)) {
206
+ value.state = 'running'
207
+ publish()
208
+ }
209
+ }
210
+
211
+ function onConfigSnapshot(snapshot) {
212
+ if (snapshot === null || typeof snapshot !== 'object') return
213
+ if (typeof snapshot.sessionId === 'string' && snapshot.sessionId.length > 0
214
+ && value.session.sessionId === undefined) {
215
+ value.session = { sessionId: snapshot.sessionId, bound: true }
216
+ }
217
+ value.model = optionValue(snapshot.options, 'model') ?? value.model
218
+ value.effort = optionValue(snapshot.options, 'effort') ?? value.effort
219
+ publish()
220
+ }
221
+
222
+ function optionValue(options, id) {
223
+ if (!Array.isArray(options)) return undefined
224
+ const option = options.find((candidate) => candidate?.id === id)
225
+ if (option === undefined) return undefined
226
+ const raw = option.currentValue ?? option.current_value
227
+ return typeof raw === 'string' ? raw : undefined
228
+ }
229
+
230
+ const core = { current, subscribe, observeClient, observeAgent }
231
+ const service = typeof ctx.provide === 'function'
232
+ ? new AcpSessionStatusService(ctx, core)
233
+ : {
234
+ current,
235
+ subscribe(listener) { return subscribe(ctx, listener) },
236
+ observeClient,
237
+ observeAgent,
238
+ }
239
+ if (typeof ctx.provide !== 'function') ctx.acpSessionStatus = service
240
+
241
+ if (typeof sessionConfig?.subscribe === 'function') {
242
+ sessionConfig.subscribe(onConfigSnapshot)
243
+ }
244
+ // Seed model/effort from anything already folded (session/load replay).
245
+ if (typeof sessionConfig?.list === 'function') {
246
+ onConfigSnapshot({ sessionId: undefined, options: sessionConfig.list() })
247
+ }
248
+ if (typeof events?.register === 'function') {
249
+ // register(observer) only: the service scopes the subscription to its
250
+ // own Context, so pass the folding object alone.
251
+ events.register({ observeClient, observeAgent })
252
+ }
253
+
254
+ return service
255
+ }
256
+
257
+ function isAuthRequired(error) {
258
+ return object(error) && (error.code === AUTH_REQUIRED_CODE
259
+ || error.code === 'auth_required' || error.code === 'AuthRequired')
260
+ }
261
+
262
+ function readString(value, ...keys) {
263
+ if (!object(value)) return undefined
264
+ for (const key of keys) {
265
+ if (typeof value[key] === 'string' && value[key].length > 0) return value[key]
266
+ }
267
+ return undefined
268
+ }
269
+
270
+ function object(value) {
271
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
272
+ }
273
+
274
+ export function apply(ctx, options = {}) {
275
+ installAcpSessionStatus(ctx, options)
276
+ }
package/lib/boot.js CHANGED
@@ -16,6 +16,9 @@ import { apply as applyCommands } from './tui-commands.js'
16
16
  import { apply as applyOverlay } from './tui-overlay.js'
17
17
  import { apply as applyPlanView, inject as planViewInject } from './plan-view.js'
18
18
  import { apply as applyStatsView, inject as statsViewInject } from './stats-view.js'
19
+ import { apply as applySessionStatus, inject as sessionStatusInject } from './acp-session-status.js'
20
+ import { apply as applyStatusView, inject as statusViewInject } from './status-view.js'
21
+ import { apply as applyDeepseekLogo, inject as deepseekLogoInject } from './deepseek-logo.js'
19
22
 
20
23
  /**
21
24
  * @param {object} [options]
@@ -37,11 +40,14 @@ export async function bootClient(options = {}) {
37
40
  await ctx.plugin({ name: 'acp-client', inject: [], apply: applyAcpClient }, acpConfig)
38
41
  await ctx.plugin({ name: 'plan-view', inject: planViewInject, apply: applyPlanView })
39
42
  await ctx.plugin({ name: 'stats-view', inject: statsViewInject, apply: applyStatsView })
43
+ await ctx.plugin({ name: 'acp-session-status', inject: sessionStatusInject, apply: applySessionStatus })
44
+ await ctx.plugin({ name: 'status-view', inject: statusViewInject, apply: applyStatusView })
45
+ await ctx.plugin({ name: 'deepseek-logo', inject: deepseekLogoInject, apply: applyDeepseekLogo })
40
46
  await ctx.plugin({
41
47
  name: 'tui-cordis-client-runner',
42
48
  inject: [
43
49
  'tuiTheme', 'tuiSlots', 'tuiCommands', 'tuiOverlay', 'acpSessionConfig',
44
- 'acpSessionPlan', 'acpSessionStats',
50
+ 'acpSessionPlan', 'acpSessionStats', 'acpSessionStatus',
45
51
  ],
46
52
  apply: applyCordisClientRunner,
47
53
  })
@@ -64,6 +70,9 @@ export async function bootClient(options = {}) {
64
70
  applyAcpClient(ctx, acpConfig)
65
71
  applyPlanView(ctx)
66
72
  applyStatsView(ctx)
73
+ applySessionStatus(ctx)
74
+ applyStatusView(ctx)
75
+ applyDeepseekLogo(ctx)
67
76
  applyCordisClientRunner(ctx)
68
77
  await applyShell(ctx, { extraArgs: options.extraArgs ?? [], tty: options.tty })
69
78
  }
package/lib/client-run.js CHANGED
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Same closure convention as the web runner: the source is an async function
5
5
  * body that returns a plugin. Open services include `tuiTheme`, `tuiSlots`,
6
- * `acpSessionConfig`, `acpSessionPlan`, `acpSessionStats`, and lifecycle-owned `timer`; `host.call` reaches this
6
+ * `acpSessionConfig`, `acpSessionPlan`, `acpSessionStats`, `acpSessionStatus`,
7
+ * and lifecycle-owned `timer`; `host.call` reaches this
7
8
  * Package's Host half.
8
9
  * No React, browser slots, TTY, or raw Host service names.
9
10
  */
@@ -16,6 +17,7 @@ const ALLOWED_INJECT = new Set([
16
17
  'acpSessionConfig',
17
18
  'acpSessionPlan',
18
19
  'acpSessionStats',
20
+ 'acpSessionStatus',
19
21
  'timer',
20
22
  ])
21
23
 
@@ -32,7 +34,7 @@ const SLOT_TEACHING =
32
34
 
33
35
  /**
34
36
  * @param {string} clientCode
35
- * @param {{ pluginId?: string, tuiTheme?: object, tuiSlots?: object, tuiCommands?: object, tuiOverlay?: object, acpSessionConfig?: object, acpSessionPlan?: object, acpSessionStats?: object, timer?: object, invoke?: Function }} env
37
+ * @param {{ pluginId?: string, tuiTheme?: object, tuiSlots?: object, tuiCommands?: object, tuiOverlay?: object, acpSessionConfig?: object, acpSessionPlan?: object, acpSessionStats?: object, acpSessionStatus?: object, timer?: object, invoke?: Function }} env
36
38
  * @returns {Promise<{ waitingFor: string[], dispose: () => void }>}
37
39
  */
38
40
  export async function applyClientHalf(clientCode, env) {
@@ -84,7 +86,7 @@ export async function applyClientHalf(clientCode, env) {
84
86
  }
85
87
  if (!ALLOWED_INJECT.has(name)) {
86
88
  throw new Error(
87
- `TUI Client inject "${name}" is not open. Open services: tuiTheme, tuiSlots, tuiCommands, tuiOverlay, acpSessionConfig, acpSessionPlan, acpSessionStats, timer.`,
89
+ `TUI Client inject "${name}" is not open. Open services: tuiTheme, tuiSlots, tuiCommands, tuiOverlay, acpSessionConfig, acpSessionPlan, acpSessionStats, acpSessionStatus, timer.`,
88
90
  )
89
91
  }
90
92
  }
@@ -137,6 +139,7 @@ function restrictedCtx(env, own, inject) {
137
139
  const sourceSessionConfig = env.acpSessionConfig
138
140
  const sourceSessionPlan = env.acpSessionPlan
139
141
  const sourceSessionStats = env.acpSessionStats
142
+ const sourceSessionStatus = env.acpSessionStatus
140
143
  const sourceTimer = env.timer
141
144
  const tuiTheme = sourceTheme === undefined
142
145
  ? undefined
@@ -254,6 +257,16 @@ function restrictedCtx(env, own, inject) {
254
257
  return own(sourceSessionStats.subscribe(listener))
255
258
  },
256
259
  }
260
+ const acpSessionStatus = sourceSessionStatus === undefined
261
+ ? undefined
262
+ : {
263
+ current() {
264
+ return sourceSessionStatus.current()
265
+ },
266
+ subscribe(listener) {
267
+ return own(sourceSessionStatus.subscribe(listener))
268
+ },
269
+ }
257
270
  const timer = sourceTimer === undefined || !inject.has('timer')
258
271
  ? undefined
259
272
  : {
@@ -272,6 +285,7 @@ function restrictedCtx(env, own, inject) {
272
285
  acpSessionConfig,
273
286
  acpSessionPlan,
274
287
  acpSessionStats,
288
+ acpSessionStatus,
275
289
  timer,
276
290
  interval: timer?.interval,
277
291
  timeout: timer?.timeout,
@@ -283,6 +297,7 @@ function restrictedCtx(env, own, inject) {
283
297
  if (name === 'acpSessionConfig') return acpSessionConfig
284
298
  if (name === 'acpSessionPlan') return acpSessionPlan
285
299
  if (name === 'acpSessionStats') return acpSessionStats
300
+ if (name === 'acpSessionStatus') return acpSessionStatus
286
301
  if (name === 'timer') return timer
287
302
  return undefined
288
303
  },
@@ -0,0 +1,62 @@
1
+ /** Built-in Client Plugin: the classic DeepSeek Harness whale lockup. */
2
+
3
+ export const name = 'deepseek-logo'
4
+ export const inject = ['tuiCommands', 'tuiOverlay']
5
+
6
+ const WHALE_LG = [
7
+ ' ▄▄▄▄ ▄▄▄███ █▄',
8
+ ' ▄▄█████████████ ███▄▄ ▄█',
9
+ ' ▄█████████████████▄▄ █████▄██████',
10
+ ' ▄█████████████████████▄ ▀██████████',
11
+ ' ▄████████████████████████▄ ▀████▀▀▀',
12
+ ' ██▀ ▀▀▀██████████▀▀█████▄████',
13
+ ' ███ ▀███████▀▄ ▀████████',
14
+ ' ███ ▀███████ ██████▀',
15
+ ' ████ ▀██████████████',
16
+ ' ████ ▀████████████',
17
+ ' ████▄ ▄▄▄ █████████▀',
18
+ ' ▀████▄ ███▄▄ ▀██████▄',
19
+ ' ▀█████████████▄▄▄████████',
20
+ ' ▀▀████████████▀▀',
21
+ ' ▀▀▀▀▀▀',
22
+ ]
23
+
24
+ const WORDMARK_SMALL = [
25
+ ' ___ ___ ___ ___ ___ ___ ___ _ __',
26
+ '| \\| __| __| _ \\/ __| __| __| |/ /',
27
+ '| |) | _|| _|| _/\\__ \\ _|| _|| \' < ',
28
+ '|___/|___|___|_| |___/___|___|_|\\_\\',
29
+ ]
30
+
31
+ export function deepseekLogoMarkdown() {
32
+ return [
33
+ '## DeepSeek Harness',
34
+ '',
35
+ '```text',
36
+ ...WHALE_LG,
37
+ '',
38
+ ...WORDMARK_SMALL,
39
+ 'H A R N E S S',
40
+ '```',
41
+ '',
42
+ '_Into the Unknown_',
43
+ ].join('\n')
44
+ }
45
+
46
+ export function apply(ctx) {
47
+ const stopCommand = ctx.tuiCommands.register({
48
+ name: 'deepseeklogo',
49
+ description: 'Open the classic DeepSeek Harness whale',
50
+ }, async () => {
51
+ ctx.tuiOverlay.openView({
52
+ id: 'deepseek-logo',
53
+ title: 'DeepSeek Harness',
54
+ nodes: [{
55
+ id: 'lockup',
56
+ kind: 'markdown',
57
+ text: deepseekLogoMarkdown(),
58
+ }],
59
+ })
60
+ })
61
+ return () => stopCommand?.()
62
+ }
package/lib/inspect.js CHANGED
@@ -10,7 +10,7 @@ import { CORDIS_METHODS } from './cordis-protocol.js'
10
10
  export const name = 'tui-cordis-client-runner'
11
11
  export const inject = [
12
12
  'tuiTheme', 'tuiSlots', 'tuiCommands', 'tuiOverlay', 'acpSessionConfig',
13
- 'acpSessionPlan', 'acpSessionStats',
13
+ 'acpSessionPlan', 'acpSessionStats', 'acpSessionStatus',
14
14
  ]
15
15
 
16
16
  const EMPTY_INPUT = Object.freeze({ type: 'object', properties: {}, additionalProperties: false })
@@ -589,6 +589,45 @@ export function statsInspectProvider(acpSessionStats) {
589
589
  }
590
590
  }
591
591
 
592
+ /** Describe non-statistics run-state facts folded from standard ACP. */
593
+ export function statusInspectProvider(acpSessionStatus) {
594
+ return {
595
+ manifest: {
596
+ id: 'Status',
597
+ description:
598
+ 'Current ACP run-state facts (connection, server, auth, session, model, '
599
+ + 'effort, permission, plan, agent) folded from standard ACP traffic. '
600
+ + 'Statistics stay in the Stats provider.',
601
+ methods: [{
602
+ name: 'current',
603
+ description: 'Return the current status snapshot and the read/subscribe contract.',
604
+ inputSchema: EMPTY_INPUT,
605
+ outputSchema: ANY_OUTPUT,
606
+ }],
607
+ },
608
+ query(method) {
609
+ if (method !== 'current') throw new Error(`unknown Status inspect method "${method}"`)
610
+ return {
611
+ current: acpSessionStatus.current(),
612
+ api: {
613
+ service: 'acpSessionStatus',
614
+ inject: ['acpSessionStatus'],
615
+ current: {
616
+ call: 'ctx.acpSessionStatus.current()',
617
+ returns: 'SessionStatusSnapshot',
618
+ },
619
+ subscribe: {
620
+ call: 'ctx.acpSessionStatus.subscribe((snapshot) => { ... })',
621
+ returns: { type: 'function', role: 'dispose', idempotent: true },
622
+ },
623
+ transport: 'standard ACP initialize, authenticate, session and session/update messages',
624
+ },
625
+ referencedTypes: ['SessionStatusSnapshot'],
626
+ }
627
+ },
628
+ }
629
+ }
630
+
592
631
  async function mountClientHalf(
593
632
  ctx,
594
633
  pluginId,
@@ -600,6 +639,7 @@ async function mountClientHalf(
600
639
  acpSessionConfig,
601
640
  acpSessionPlan,
602
641
  acpSessionStats,
642
+ acpSessionStatus,
603
643
  timer,
604
644
  invoke,
605
645
  ) {
@@ -607,7 +647,7 @@ async function mountClientHalf(
607
647
  return applyClientHalf(clientCode, {
608
648
  pluginId,
609
649
  tuiTheme, tuiSlots, tuiCommands, tuiOverlay, acpSessionConfig, acpSessionPlan,
610
- acpSessionStats, timer, invoke,
650
+ acpSessionStats, acpSessionStatus, timer, invoke,
611
651
  })
612
652
  }
613
653
 
@@ -619,7 +659,7 @@ async function mountClientHalf(
619
659
  applied = await applyClientHalf(clientCode, {
620
660
  pluginId,
621
661
  tuiTheme, tuiSlots, tuiCommands, tuiOverlay, acpSessionConfig, acpSessionPlan,
622
- acpSessionStats, timer, invoke,
662
+ acpSessionStats, acpSessionStatus, timer, invoke,
623
663
  })
624
664
  return applied.dispose
625
665
  },
@@ -650,6 +690,7 @@ async function mountClientHalf(
650
690
  * acpSessionConfig?: object,
651
691
  * acpSessionPlan?: object,
652
692
  * acpSessionStats?: object,
693
+ * acpSessionStatus?: object,
653
694
  * requestAgent: (method: string, params?: object) => Promise<unknown>,
654
695
  * }} opts
655
696
  * @returns {{ onHost: (message: object) => void }}
@@ -657,7 +698,7 @@ async function mountClientHalf(
657
698
  export function attachTuiClient(opts) {
658
699
  const {
659
700
  ctx, tuiTheme, tuiSlots, tuiCommands, tuiOverlay, acpSessionConfig, acpSessionPlan,
660
- acpSessionStats,
701
+ acpSessionStats, acpSessionStatus,
661
702
  requestAgent,
662
703
  } = opts
663
704
  const providers = [
@@ -668,6 +709,7 @@ export function attachTuiClient(opts) {
668
709
  ...(acpSessionConfig === undefined ? [] : [configOptionsInspectProvider(acpSessionConfig)]),
669
710
  ...(acpSessionPlan === undefined ? [] : [planInspectProvider(acpSessionPlan)]),
670
711
  ...(acpSessionStats === undefined ? [] : [statsInspectProvider(acpSessionStats)]),
712
+ ...(acpSessionStatus === undefined ? [] : [statusInspectProvider(acpSessionStatus)]),
671
713
  ]
672
714
  const timer = createClientTimer()
673
715
  /** @type {Map<string, () => void>} */
@@ -765,6 +807,7 @@ export function attachTuiClient(opts) {
765
807
  acpSessionConfig,
766
808
  acpSessionPlan,
767
809
  acpSessionStats,
810
+ acpSessionStatus,
768
811
  timer,
769
812
  async (method, args) => {
770
813
  const answered = await requestAgent(CORDIS_METHODS.pluginInvoke, {
@@ -933,13 +976,14 @@ export function apply(ctx) {
933
976
  const acpSessionConfig = ctx.acpSessionConfig ?? ctx.get?.('acpSessionConfig')
934
977
  const acpSessionPlan = ctx.acpSessionPlan ?? ctx.get?.('acpSessionPlan')
935
978
  const acpSessionStats = ctx.acpSessionStats ?? ctx.get?.('acpSessionStats')
979
+ const acpSessionStatus = ctx.acpSessionStatus ?? ctx.get?.('acpSessionStatus')
936
980
  let client
937
981
  const service = {
938
982
  bindTransport(requestAgent) {
939
983
  client?.dispose()
940
984
  client = attachTuiClient({
941
985
  ctx, tuiTheme, tuiSlots, tuiCommands, tuiOverlay, acpSessionConfig, acpSessionPlan,
942
- acpSessionStats,
986
+ acpSessionStats, acpSessionStatus,
943
987
  requestAgent,
944
988
  })
945
989
  const attached = client
package/lib/plan-view.js CHANGED
@@ -75,18 +75,23 @@ function viewNodes(plan) {
75
75
  if (plan.kind === 'file') {
76
76
  return [{ id: 'file', kind: 'notice', level: 'info', text: plan.uri }]
77
77
  }
78
- return plan.entries.map((entry, index) => ({
79
- id: `step-${index + 1}`,
80
- kind: 'generic',
81
- title: entry.content,
82
- body: entry.priority ? `priority · ${entry.priority}` : '',
83
- ...nodeStatus(entry.status),
84
- }))
85
- }
86
-
87
- function nodeStatus(status) {
88
- if (status === 'completed') return { status: 'ok' }
89
- if (status === 'in_progress') return { status: 'running' }
90
- if (status === 'cancelled' || status === 'failed') return { status: 'err' }
91
- return {}
78
+ // Items render as one markdown node: a task-list review the transcript
79
+ // pipeline can fully render (headings, bold, strikethrough).
80
+ const total = plan.entries.length
81
+ const completed = plan.entries.filter((entry) => entry.status === 'completed').length
82
+ const lines = [`## Plan · ${completed}/${total}`, '']
83
+ for (const entry of plan.entries) {
84
+ const content = entry.content.replace(/\s*\n\s*/g, ' ')
85
+ let item
86
+ if (entry.status === 'completed') {
87
+ item = `- [x] ${content}`
88
+ } else if (entry.status === 'cancelled' || entry.status === 'failed') {
89
+ item = `- [ ] ~~${content}~~`
90
+ } else {
91
+ item = entry.status === 'in_progress' ? `- [ ] **${content}**` : `- [ ] ${content}`
92
+ }
93
+ if (entry.priority) item += ` · priority · ${entry.priority}`
94
+ lines.push(item)
95
+ }
96
+ return [{ id: 'content', kind: 'markdown', text: lines.join('\n') }]
92
97
  }
package/lib/stats-view.js CHANGED
@@ -87,4 +87,4 @@ function formatRate(value) {
87
87
  return String(Math.round(value * 10) / 10)
88
88
  }
89
89
 
90
- export { formatDuration, formatTokens, nodesOf }
90
+ export { formatDuration, formatRate, formatTokens, nodesOf }
@@ -0,0 +1,70 @@
1
+ /** Built-in Client Plugin: the `/status` run-state overlay. */
2
+
3
+ import { formatDuration, formatRate, formatTokens } from './stats-view.js'
4
+
5
+ export const name = 'status-view'
6
+ export const inject = ['acpSessionStatus', 'acpSessionStats', 'tuiCommands', 'tuiOverlay']
7
+
8
+ export function apply(ctx) {
9
+ const stopCommand = ctx.tuiCommands.register({
10
+ name: 'status',
11
+ description: 'Session run state and key stats',
12
+ }, async () => {
13
+ ctx.tuiOverlay.openView({
14
+ id: 'status',
15
+ title: 'Status',
16
+ nodes: [{
17
+ id: 'content',
18
+ kind: 'markdown',
19
+ text: statusMarkdown(
20
+ ctx.acpSessionStatus.current(),
21
+ ctx.acpSessionStats.current(),
22
+ ),
23
+ }],
24
+ })
25
+ })
26
+ return () => stopCommand?.()
27
+ }
28
+
29
+ /**
30
+ * One `## status` markdown node. Run-state facts come from
31
+ * `acpSessionStatus`; every token/turn/step/timing fact comes from
32
+ * `acpSessionStats.current()` — the same snapshot `stats-view` renders in
33
+ * the composer dock, so the two readouts can never drift apart.
34
+ */
35
+ function statusMarkdown(status, stats) {
36
+ const usage = stats?.usage ?? {}
37
+ const folded = stats?.stats ?? {}
38
+ const total = (usage.input ?? 0) + (usage.output ?? 0)
39
+ + (usage.cached ?? 0) + (usage.reasoning ?? 0)
40
+ const lines = ['## status', '']
41
+ lines.push(`- state · ${status.state ?? 'idle'}`)
42
+ lines.push(`- acp · ${status.connection ?? 'not attached'}`)
43
+ if (status.auth?.status !== undefined) {
44
+ lines.push(`- auth · ${status.auth.status}${status.auth.method ? ` · ${status.auth.method}` : ''}`)
45
+ }
46
+ lines.push(`- session · ${status.session?.bound ? status.session.sessionId : 'unbound'}`)
47
+ if (status.server !== undefined) lines.push(`- server · ${status.server}`)
48
+ if (status.model !== undefined) lines.push(`- model · ${status.model}`)
49
+ if (status.agent !== undefined) lines.push(`- agent · ${status.agent}`)
50
+ if (status.permission !== undefined) lines.push(`- permission · ${status.permission}`)
51
+ if (status.plan !== undefined) lines.push(`- plan · ${status.plan ? 'on' : 'off'}`)
52
+ if (status.effort !== undefined) lines.push(`- effort · ${status.effort}`)
53
+ lines.push(
54
+ `- tokens · ↑${formatTokens(usage.input ?? 0)} ↓${formatTokens(usage.output ?? 0)}`
55
+ + ` (cached ${formatTokens(usage.cached ?? 0)}) · Σ ${formatTokens(total)}`,
56
+ )
57
+ lines.push(`- turns · ${folded.turns ?? 0} · steps · ${folded.steps ?? 0}`)
58
+ lines.push(`- LLM · ${formatDuration(folded.llmMillis ?? 0)} · tool · ${formatDuration(folded.toolMillis ?? 0)}`)
59
+ if ((folded.ttftCount ?? 0) > 0) {
60
+ lines.push(`- TTFT avg · ${formatDuration(folded.ttftTotalMillis / folded.ttftCount)}`)
61
+ }
62
+ const llmMillis = folded.llmMillis ?? 0
63
+ const output = usage.output ?? 0
64
+ if (llmMillis > 0 && output > 0) {
65
+ lines.push(`- rate · ${formatRate(output / (llmMillis / 1000))} tok/s`)
66
+ }
67
+ return lines.join('\n')
68
+ }
69
+
70
+ export { statusMarkdown }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "martty",
3
- "version": "0.2.13",
3
+ "version": "0.2.15-beta.0",
4
4
  "description": "Terminal-native ACP client UI; Cordis client tree, any ACP agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -15,7 +15,7 @@
15
15
  "main": "lib/index.js",
16
16
  "scripts": {
17
17
  "pretest": "node --test ../scripts/workflow-release.test.mjs ../scripts/package-alias.test.mjs",
18
- "test": "node --test ../scripts/package-native.test.mjs ../scripts/check-release-tag.test.mjs ../scripts/check-static-elf.test.mjs ../scripts/smoke-old-linux.test.mjs ../scripts/cargo-guard.test.mjs ../scripts/build-npm.test.mjs ../scripts/client-profile.test.mjs ../scripts/plugin-runner.test.mjs ../scripts/profile-link-resolution.test.mjs ../scripts/release.test.mjs ../scripts/jsonrpc-line-transport.test.mjs ../scripts/tui-theme.test.mjs ../scripts/tui-slots.test.mjs ../scripts/tui-commands.test.mjs ../scripts/tui-overlay.test.mjs ../scripts/mux.test.mjs ../scripts/acp-client.test.mjs ../scripts/acp-client-events.test.mjs ../scripts/acp-session-config.test.mjs ../scripts/acp-session-plan.test.mjs ../scripts/acp-session-stats.test.mjs ../scripts/plan-view.test.mjs ../scripts/stats-view.test.mjs ../scripts/runner.test.mjs ../scripts/inspect.test.mjs ../scripts/creator-overlay.test.mjs ../scripts/real-agent-e2e.test.mjs"
18
+ "test": "node --test ../scripts/package-native.test.mjs ../scripts/check-release-tag.test.mjs ../scripts/check-static-elf.test.mjs ../scripts/smoke-old-linux.test.mjs ../scripts/cargo-guard.test.mjs ../scripts/build-npm.test.mjs ../scripts/client-profile.test.mjs ../scripts/plugin-runner.test.mjs ../scripts/profile-link-resolution.test.mjs ../scripts/release.test.mjs ../scripts/jsonrpc-line-transport.test.mjs ../scripts/tui-theme.test.mjs ../scripts/tui-slots.test.mjs ../scripts/tui-commands.test.mjs ../scripts/tui-overlay.test.mjs ../scripts/mux.test.mjs ../scripts/acp-client.test.mjs ../scripts/acp-client-events.test.mjs ../scripts/acp-session-config.test.mjs ../scripts/acp-session-plan.test.mjs ../scripts/acp-session-stats.test.mjs ../scripts/acp-session-status.test.mjs ../scripts/plan-view.test.mjs ../scripts/stats-view.test.mjs ../scripts/status-view.test.mjs ../scripts/deepseek-logo.test.mjs ../scripts/runner.test.mjs ../scripts/inspect.test.mjs ../scripts/creator-overlay.test.mjs ../scripts/real-agent-e2e.test.mjs"
19
19
  },
20
20
  "publishConfig": {
21
21
  "access": "public",
@@ -30,8 +30,11 @@
30
30
  "./overlay": "./lib/tui-overlay.js",
31
31
  "./session-plan": "./lib/acp-session-plan.js",
32
32
  "./session-stats": "./lib/acp-session-stats.js",
33
+ "./session-status": "./lib/acp-session-status.js",
33
34
  "./plan-view": "./lib/plan-view.js",
34
35
  "./stats-view": "./lib/stats-view.js",
36
+ "./status-view": "./lib/status-view.js",
37
+ "./deepseek-logo": "./lib/deepseek-logo.js",
35
38
  "./right-demo": "./lib/right-demo.js",
36
39
  "./acp-client": "./lib/acp-client.js",
37
40
  "./acp-client-events": "./lib/acp-client-events.js",
@@ -62,6 +62,7 @@ Use the narrowest capability:
62
62
  | Current ACP Session option | advertised config-option Provider | `acpSessionConfig` |
63
63
  | Current structured ACP Plan | `Plans` | `acpSessionPlan` |
64
64
  | Current ACP Session statistics | `Stats` | `acpSessionStats` |
65
+ | Current ACP run-state facts | `Status` | `acpSessionStatus` |
65
66
 
66
67
  A transient control is not a side panel. When the user did not request
67
68
  persistent content, do not query Slots or mount `chrome.right`.
@@ -92,7 +93,8 @@ compose only in the Plugin code. The services do not imply one another:
92
93
  asked for persistent shell UI. Use stable node ids and update the existing
93
94
  contribution instead of creating parallel panels. Select the live seat from
94
95
  `Slots.list`: all current seats aggregate contributors. Use
95
- `conversation.input.dock` for content needing its own line and keep
96
+ `conversation.input.dock` for content needing its own line (it owns the
97
+ single cap row and displaces the tip line while present) and keep
96
98
  `conversation.composer.dock` contributions compact.
97
99
  - **Overlays:** open a transient native control only in response to the
98
100
  interaction that needs it. A command is one possible trigger, not part of
Binary file
Binary file
Binary file
Binary file
Binary file