import { stripAnsi } from '@hermes/shared/ansi'
import { compactNumber } from '@hermes/shared/format'

import { usageBarsText } from '../../../components/overlayPrimitives.js'
import { introMsg, toTranscriptMessages } from '../../../domain/messages.js'
import { sessionScopedModelArg, TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js'
import type {
  BackgroundStartResponse,
  ConfigGetValueResponse,
  ConfigSetResponse,
  SessionBranchResponse,
  SessionCompressResponse,
  SessionUsageResponse,
  SlashExecResponse,
  VoiceToggleResponse
} from '../../../gatewayTypes.js'
import { t } from '../../../i18n/runtime.js'
import { formatVoiceRecordKey, parseVoiceRecordKey } from '../../../lib/platform.js'
import type { PanelSection } from '../../../types.js'
import { applyConfiguredTuiTheme } from '../../createGatewayEventHandler.js'
import { DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES, type IndicatorStyle } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js'
import { getUiState, patchUiState } from '../../uiStore.js'
import { canonicalReasoning, canonicalUsage } from '../canonicalSessionCommands.js'
import { runCanonicalSessionControl } from '../canonicalSessionControls.js'
import type { SlashCommand, SlashRunCtx } from '../types.js'
import { usageTotalsSections } from '../usagePanel.js'

const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`)
const REASONING_SESSION_FLAGS = new Set(['--session'])
const REASONING_GLOBAL_FLAGS = new Set(['--global'])

const COMPRESS_AUTHORITY_KEYS = [
  'stored_session_id',
  'execution_epoch',
  'execution_generation',
  'execution_state',
  'running'
] as const

// Compression is not attachment: a delayed reply cannot replace a
// newer turn/owner, nor replace its transcript with an old snapshot.
function isStaleCompressReply(r: SessionCompressResponse, current: ReturnType<typeof getUiState>, ctx: SlashRunCtx) {
  if (current.busy !== ctx.ui.busy || COMPRESS_AUTHORITY_KEYS.some(key => current.info?.[key] !== ctx.ui.info?.[key])) {
    return true
  }

  return (
    current.info?.execution_generation !== undefined &&
    (r.info?.execution_epoch !== current.info.execution_epoch ||
      !Number.isSafeInteger(r.info?.execution_generation) ||
      (r.info?.execution_generation ?? -1) < current.info.execution_generation)
  )
}

type FastModeWord = 'fast' | 'normal' | 'ultrafast'

// `config.get/set fast` answer fast | ultrafast | normal (auto/cold windows read as normal here).
const fastModeWord = (value: unknown): FastModeWord => (value === 'fast' || value === 'ultrafast' ? value : 'normal')

const modelValueForConfigSet = (arg: string) => {
  const trimmed = arg.trim()

  if (!trimmed) {
    return trimmed
  }

  if (TUI_SESSION_MODEL_RE.test(trimmed)) {
    return sessionScopedModelArg(trimmed)
  }

  return trimmed
}

const reasoningConfigPayload = (arg: string, sid: string) => {
  const parts = arg.trim().split(/\s+/).filter(Boolean)
  let scope = ''
  const valueParts: string[] = []

  for (const part of parts) {
    const flag = part.toLowerCase()

    if (REASONING_GLOBAL_FLAGS.has(flag)) {
      scope = 'global'

      continue
    }

    if (REASONING_SESSION_FLAGS.has(flag)) {
      // Session scope is the default; accept the flag for parity with /model.
      if (!scope) {
        scope = 'session'
      }

      continue
    }

    valueParts.push(part)
  }

  const value = valueParts.join(' ')

  return {
    key: 'reasoning',
    session_id: sid,
    value,
    ...(scope ? { scope } : {})
  }
}

export const sessionCommands: SlashCommand[] = [
  {
    aliases: ['background'],
    help: 'launch a background prompt',
    name: 'bg',
    run: (arg, ctx) => {
      if (!arg) {
        return ctx.transcript.sys(t('slashCmd.session.bg.usage'))
      }

      ctx.gateway.rpc<BackgroundStartResponse>('prompt.background', { session_id: ctx.sid, text: arg }).then(
        ctx.guarded<BackgroundStartResponse>(r => {
          if (!r.task_id) {
            return
          }

          patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(r.task_id!) }))
          ctx.transcript.sys(t('slashCmd.session.bg.started', r.task_id))
        })
      )
    }
  },

  {
    help: 'ask a side question about this conversation',
    name: 'btw',
    run: (arg, ctx) => {
      if (!arg) {
        return ctx.transcript.sys(t('slashCmd.session.btw.usage'))
      }

      ctx.gateway.rpc<BackgroundStartResponse>('prompt.btw', { session_id: ctx.sid, text: arg }).then(
        ctx.guarded<BackgroundStartResponse>(r => {
          if (!r.task_id) {
            return
          }

          ctx.transcript.sys(t('slashCmd.session.btw.answering', r.task_id))
        })
      )
    }
  },

  {
    help: 'change or show model',
    name: 'model',
    run: (arg, ctx) => {
      // No busy guard here (unlike session switching). A model change is a
      // session-scoped config.set: idle it switches immediately; mid-turn the
      // gateway QUEUES it and applies it at the next turn start (returning
      // deferred:true) instead of rejecting. Either way the pick sticks without
      // interrupting the stream or waiting on the swap.
      if (!arg.trim()) {
        return patchOverlayState({ modelPicker: true })
      }

      if (arg.trim() === '--refresh') {
        return patchOverlayState({ modelPicker: { refresh: true } })
      }

      if (ctx.gateway.gw.isCanonical) {
        return runCanonicalSessionControl('model', arg, ctx)
      }

      const switchModel = (confirmExpensiveModel = false) =>
        ctx.gateway
          .rpc<ConfigSetResponse>('config.set', {
            confirm_expensive_model: confirmExpensiveModel,
            key: 'model',
            session_id: ctx.sid,
            value: modelValueForConfigSet(arg)
          })
          .then(
            ctx.guarded<ConfigSetResponse>(r => {
              if (r.confirm_required) {
                patchOverlayState({
                  confirm: {
                    cancelLabel: t('slashCmd.session.model.cancel'),
                    confirmLabel: t('slashCmd.session.model.switchAnyway'),
                    danger: true,
                    detail: r.confirm_message || r.warning || t('slashCmd.session.model.expensiveDetail'),
                    onConfirm: () => switchModel(true),
                    title: t('slashCmd.session.model.expensiveTitle')
                  }
                })

                return
              }

              if (!r.value) {
                return ctx.transcript.sys(t('slashCmd.session.model.invalidResponse'))
              }

              ctx.transcript.sys(
                r.deferred
                  ? t('slashCmd.session.model.switchedDeferred', r.value)
                  : t('slashCmd.session.model.switched', r.value)
              )
              ctx.local.maybeWarn(r)

              patchUiState(state => ({
                ...state,
                info: state.info ? { ...state.info, model: r.value! } : { model: r.value!, skills: {}, tools: {} }
              }))
            })
          )

      switchModel()
    }
  },

  {
    aliases: ['switch', 'session', 'resume'],
    help: 'browse, switch, or resume sessions',
    name: 'sessions',
    run: (arg, ctx) => {
      const trimmed = arg.trim()

      // A new *live* session keeps the current one running in the background
      // (it doesn't close it), so fanning out while busy is allowed — that's
      // the whole point of multiple live sessions.
      if (trimmed.toLowerCase() === 'new') {
        return ctx.session.newLiveSession()
      }

      // `/resume <id|title>` (and `/sessions <id>`) load a cold session and
      // CLOSE the current one, so guard it while a turn is in-flight to avoid
      // corrupting streaming/busy state. Bare opens the overlay to browse.
      if (trimmed) {
        if (ctx.session.guardBusySessionSwitch(t('slashCmd.session.sessions.busyGuardAction'))) {
          return
        }

        return ctx.session.resumeById(trimmed)
      }

      patchOverlayState({ sessions: true })
    }
  },

  {
    help: 'attach an image',
    name: 'image',
    run: (arg, ctx) => ctx.composer.attachImagePath(arg)
  },

  {
    help: 'switch personality for this session',
    name: 'personality',
    run: (arg, ctx) => {
      // The bare form is the owner's /personality listing (one behaviour on every surface).
      if (!arg && ctx.gateway.gw?.isCanonical) {
        return ctx.gateway.gw
          .request<SlashExecResponse>('slash.exec', { command: 'personality', session_id: ctx.sid })
          .then(ctx.guarded<SlashExecResponse>(r => r.output && ctx.transcript.sys(r.output)))
          .catch(ctx.guardedErr)
      }

      if (!arg) {
        return
      }

      ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'personality', session_id: ctx.sid, value: arg }).then(
        ctx.guarded<ConfigSetResponse>(r => {
          if (r.history_reset) {
            ctx.session.resetVisibleHistory(r.info ?? null)
          }

          const value = r.value || t('slashCmd.session.personality.defaultValue')
          ctx.transcript.sys(
            r.history_reset
              ? t('slashCmd.session.personality.changedCleared', value)
              : t('slashCmd.session.personality.changed', value)
          )
          ctx.local.maybeWarn(r)
        })
      )
    }
  },

  {
    help: 'compress transcript',
    name: 'compress',
    run: (arg, ctx) => {
      if (ctx.gateway.gw.isCanonical) {
        return runCanonicalSessionControl('compress', arg, ctx)
      }

      ctx.gateway
        .rpc<SessionCompressResponse>('session.compress', {
          session_id: ctx.sid,
          ...(arg ? { focus_topic: arg } : {})
        })
        .then(
          ctx.guarded<SessionCompressResponse>(r => {
            const current = getUiState()

            if (isStaleCompressReply(r, current, ctx)) {
              return
            }

            const info = r.info ? { ...current.info, ...r.info } : current.info

            if (Array.isArray(r.messages)) {
              const rows = toTranscriptMessages(r.messages)

              ctx.transcript.setHistoryItems(info ? [introMsg(info), ...rows] : rows)
            }

            if (r.info) {
              patchUiState({ info })
            }

            if (r.usage) {
              patchUiState(state => ({ ...state, usage: { ...state.usage, ...r.usage } }))
            }

            if (r.summary?.headline) {
              const prefix = r.summary.noop ? '' : '✓ '

              ctx.transcript.sys(`${prefix}${r.summary.headline}`)

              if (r.summary.token_line) {
                ctx.transcript.sys(`  ${r.summary.token_line}`)
              }

              if (r.summary.note) {
                ctx.transcript.sys(`  ${r.summary.note}`)
              }

              return
            }

            if ((r.removed ?? 0) <= 0) {
              return ctx.transcript.sys(t('slashCmd.session.compress.nothing'))
            }

            const removed = r.removed ?? 0
            const count = String(removed)
            const tok = r.usage?.total ? t('slashCmd.session.compress.tokSuffix', compactNumber(r.usage.total)) : ''

            ctx.transcript.sys(
              (removed === 1
                ? t('slashCmd.session.compress.compressedOne', count)
                : t('slashCmd.session.compress.compressedOther', count)) + tok
            )
          })
        )
        .catch(ctx.guardedErr)
    }
  },

  {
    aliases: ['fork'],
    help: 'branch the session',
    name: 'branch',
    run: (arg, ctx) => {
      if (ctx.gateway.gw.isCanonical) {
        return runCanonicalSessionControl('branch', arg, ctx)
      }

      ctx.gateway.rpc<SessionBranchResponse>('session.branch', { name: arg, session_id: ctx.sid }).then(
        ctx.guarded<SessionBranchResponse>(r => {
          if (!r.session_id) {
            return
          }

          // Resume hydrates destination authority/history before closing the
          // source; changing only sid would inherit the source owner's epoch.
          ctx.session.resumeById(r.session_id)
          ctx.transcript.sys(t('slashCmd.session.branch.branched', r.title ?? ''))
        })
      )
    }
  },

  {
    help: 'voice mode: [on|off|tts|status]',
    name: 'voice',
    run: (arg, ctx) => {
      const normalized = (arg ?? '').trim().toLowerCase()

      const action =
        normalized === 'on' || normalized === 'off' || normalized === 'tts' || normalized === 'status'
          ? normalized
          : 'status'

      // Read-aloud ran in the sidecar's own turn; a shared-gateway turn runs on the owner, which
      // never speaks a TUI reply, so the toggle would say "on" and stay silent.
      if (action === 'tts' && ctx.gateway.gw?.isCanonical) {
        return ctx.transcript.sys(t('canonical.controls.notAvailable', 'voice tts'))
      }

      // T9: the shared owner holds a session's voice mode (its /voice handler, per session). The
      // sidecar's voice.toggle flipped the owner's process-global HERMES_VOICE for every chat; run
      // the owner's handler instead and mirror the mode it reports back.
      if (ctx.gateway.gw?.isCanonical && ctx.sid) {
        const sid = ctx.sid
        const command = normalized === 'on' || normalized === 'off' ? `voice ${normalized}` : 'voice status'

        return void ctx.gateway.gw
          .request<SlashExecResponse>('slash.exec', { command, session_id: sid })
          .then(r => {
            if (ctx.stale()) {
              return
            }

            const text = r?.output?.trim()

            if (text) {
              ctx.transcript.sys(text)
            }

            return ctx.gateway.rpc<ConfigGetValueResponse>('config.get', { key: 'voice', session_id: sid })
          })
          .then(r => {
            if (!r || ctx.stale()) {
              return
            }

            // Local sessions refuse `/voice tts` (the owner never speaks a TUI reply), so speech
            // output stays off; the mic mode follows the gateway's per-session state.
            ctx.voice.setVoiceEnabled(r.value === 'voice_only' || r.value === 'all')
            ctx.voice.setVoiceTts(false)
          })
          .catch(ctx.guardedErr)
      }

      ctx.gateway.rpc<VoiceToggleResponse>('voice.toggle', { action }).then(
        ctx.guarded<VoiceToggleResponse>(r => {
          ctx.voice.setVoiceEnabled(!!r.enabled)
          ctx.voice.setVoiceTts(!!r.tts)

          // Render the configured record key (config.yaml ``voice.record_key``)
          // instead of hardcoded "Ctrl+B" — the gateway response carries the
          // current value so /voice status and /voice on stay in sync with
          // both the CLI and the TUI's actual binding (#18994).
          //
          // Copilot review on #19835 caught that rendering from the fresh
          // backend response WITHOUT updating the frontend ``voice.recordKey``
          // state would skew display and binding between config-edit and
          // the next ``mtime`` poll (~5s). Parse once, push into state so
          // ``useInputHandlers()`` picks up the new binding immediately.
          //
          // Round-2 follow-up: only push state when the response actually
          // carries ``record_key`` — otherwise an older gateway (or a future
          // branch that forgets to include it) would clobber a custom user
          // binding back to the default on every /voice invocation. The
          // label still falls back to the documented default for display.
          const parsed = r.record_key ? parseVoiceRecordKey(r.record_key) : undefined

          if (parsed) {
            ctx.voice.setVoiceRecordKey(parsed)
          }

          const recordKeyLabel = formatVoiceRecordKey(parsed ?? parseVoiceRecordKey('ctrl+b'))

          // Match CLI's _show_voice_status / _enable_voice_mode /
          // _toggle_voice_tts output shape so users don't have to learn
          // two vocabularies.
          if (action === 'status') {
            const mode = r.enabled ? t('slashCmd.session.voice.on') : t('slashCmd.session.voice.off')
            const tts = r.tts ? t('slashCmd.session.voice.on') : t('slashCmd.session.voice.off')
            ctx.transcript.sys(t('slashCmd.session.voice.statusTitle'))
            ctx.transcript.sys(t('slashCmd.session.voice.statusMode', mode))
            ctx.transcript.sys(t('slashCmd.session.voice.statusTts', tts))
            ctx.transcript.sys(t('slashCmd.session.voice.statusRecordKey', recordKeyLabel))

            // CLI's "Requirements:" block — surfaces STT/audio setup issues
            // so the user sees "STT provider: MISSING ..." instead of
            // silently failing on every record-key press.
            if (r.details) {
              ctx.transcript.sys('')
              ctx.transcript.sys(t('slashCmd.session.voice.requirements'))

              for (const line of r.details.split('\n')) {
                if (line.trim()) {
                  ctx.transcript.sys(`    ${line}`)
                }
              }
            }

            return
          }

          if (action === 'tts') {
            ctx.transcript.sys(r.tts ? t('slashCmd.session.voice.ttsEnabled') : t('slashCmd.session.voice.ttsDisabled'))

            return
          }

          // on/off — mirror cli.py:_enable_voice_mode's 3-line output
          if (r.enabled) {
            ctx.transcript.sys(r.tts ? t('slashCmd.session.voice.enabledWithTts') : t('slashCmd.session.voice.enabled'))
            ctx.transcript.sys(t('slashCmd.session.voice.recordHint', recordKeyLabel))

            // Spoken-stop hint — backend-sourced from voice.stop_phrases so a
            // custom phrase renders correctly; absent/empty means the feature
            // is disabled (stop_phrases: []) and no hint is shown.
            if (r.stop_hint) {
              ctx.transcript.sys(`  ${r.stop_hint}`)
            }

            ctx.transcript.sys(t('slashCmd.session.voice.ttsHint'))
            ctx.transcript.sys(t('slashCmd.session.voice.offHint'))
          } else {
            ctx.transcript.sys(t('slashCmd.session.voice.disabled'))
          }
        })
      )
    }
  },

  {
    help: 'toggle / adopt / resize an animated pet',
    name: 'pet',
    usage: '/pet [toggle | list | scale <n> | <slug>]',
    run: (arg, ctx, cmd) => {
      const sub = arg.trim().toLowerCase()

      // Gallery picker — the interactive browse surface.
      if (sub === 'list') {
        return patchOverlayState({ petPicker: true })
      }

      // Bare /pet and /pet toggle flip display.pet.enabled via the slash worker.
      ctx.gateway.gw
        .request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
        .then(
          ctx.guarded<SlashExecResponse>(r => {
            const body = r.output || t('slashCmd.session.pet.noOutput')
            ctx.transcript.sys(r.warning ? t('slashCmd.session.pet.warning', r.warning, body) : body)
          })
        )
        .catch(ctx.guardedErr)
    }
  },

  {
    help: 'pin light/dark mode or trust auto-detection (usage: /theme [auto|light|dark])',
    name: 'theme',
    usage: '/theme [auto|light|dark]',
    run: (arg, ctx) => {
      const value = arg.trim().toLowerCase()

      if (!value) {
        return ctx.gateway
          .rpc<ConfigGetValueResponse>('config.get', { key: 'theme' })
          .then(
            ctx.guarded<ConfigGetValueResponse>(r =>
              ctx.transcript.sys(t('slashCmd.session.theme.current', r.value || 'auto'))
            )
          )
      }

      if (!['auto', 'light', 'dark'].includes(value)) {
        return ctx.transcript.sys(t('slashCmd.session.theme.usage'))
      }

      // Apply only after the write is confirmed (mirrors /indicator): a
      // failed config.set must not leave the session showing a theme that
      // reverts on restart. A few ms later than an optimistic flip, but the
      // env/theme state and config.yaml never disagree.
      ctx.gateway
        .rpc<ConfigSetResponse>('config.set', { key: 'theme', value })
        .then(
          ctx.guarded<ConfigSetResponse>(r => {
            if (r.value === undefined) {
              return
            }

            applyConfiguredTuiTheme(value)
            ctx.transcript.sys(t('slashCmd.session.theme.switched', value))
          })
        )
        .catch(ctx.guardedErr)
    }
  },

  {
    help: 'switch theme skin (fires skin.changed)',
    name: 'skin',
    run: (arg, ctx) => {
      if (!arg) {
        return ctx.gateway
          .rpc<ConfigGetValueResponse>('config.get', { key: 'skin' })
          .then(
            ctx.guarded<ConfigGetValueResponse>(r =>
              ctx.transcript.sys(t('slashCmd.session.skin.current', r.value || t('slashCmd.session.skin.defaultValue')))
            )
          )
      }

      ctx.gateway
        .rpc<ConfigSetResponse>('config.set', { key: 'skin', value: arg })
        .then(
          ctx.guarded<ConfigSetResponse>(
            r => r.value && ctx.transcript.sys(t('slashCmd.session.skin.switched', r.value))
          )
        )
    }
  },

  {
    help: 'pick the busy indicator: kaomoji (default), emoji, unicode (braille), or ascii',
    name: 'indicator',
    usage: `/indicator [${INDICATOR_STYLES.join('|')}]`,
    run: (arg, ctx) => {
      const value = arg.trim().toLowerCase()

      if (!value) {
        return ctx.gateway
          .rpc<ConfigGetValueResponse>('config.get', { key: 'indicator' })
          .then(
            ctx.guarded<ConfigGetValueResponse>(r =>
              ctx.transcript.sys(t('slashCmd.session.indicator.current', r.value || DEFAULT_INDICATOR_STYLE))
            )
          )
      }

      if (!(INDICATOR_STYLES as readonly string[]).includes(value)) {
        return ctx.transcript.sys(t('slashCmd.session.indicator.usage', INDICATOR_STYLES.join('|')))
      }

      ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'indicator', value }).then(
        ctx.guarded<ConfigSetResponse>(r => {
          if (!r.value) {
            return
          }

          // Hot-swap the running TUI immediately so the next render
          // uses the new style without waiting for the 5s mtime poll
          // to re-apply config.full.
          patchUiState({ indicatorStyle: value as IndicatorStyle })
          ctx.transcript.sys(t('slashCmd.session.indicator.switched', r.value))
        })
      )
    }
  },

  {
    help: 'toggle yolo mode (per-session approvals)',
    name: 'yolo',
    run: (_arg, ctx) => {
      ctx.gateway
        .rpc<ConfigSetResponse>('config.set', { key: 'yolo', session_id: ctx.sid })
        .then(
          ctx.guarded<ConfigSetResponse>(r =>
            ctx.transcript.sys(r.value === '1' ? t('slashCmd.session.yolo.on') : t('slashCmd.session.yolo.off'))
          )
        )
    }
  },

  {
    help: 'inspect or set reasoning effort (updates live agent)',
    name: 'reasoning',
    run: (arg, ctx) => {
      if (!arg) {
        ctx.ran?.()

        return ctx.gateway
          .rpc<ConfigGetValueResponse>('config.get', { key: 'reasoning', session_id: ctx.sid })
          .then(
            ctx.guarded<ConfigGetValueResponse>(
              r =>
                r.value &&
                ctx.transcript.sys(t('slashCmd.session.reasoning.currentWithDisplay', r.value, r.display || 'hide'))
            )
          )
      }

      const payload = reasoningConfigPayload(arg, ctx.sid ?? '')

      // The shared owner sets a session level through its frozen-policy mutation; `--global`, `reset`
      // and the display words are the owner's `/reasoning` handler behind `config.set reasoning`.
      if (ctx.gateway.gw?.isCanonical && canonicalReasoning(payload, ctx)) {
        return
      }

      ctx.gateway.rpc<ConfigSetResponse>('config.set', payload).then(
        ctx.guarded<ConfigSetResponse>(r => {
          if (!r.value) {
            return
          }

          if (r.value === 'hide') {
            patchUiState(state => ({
              ...state,
              sections: { ...state.sections, thinking: 'hidden' },
              showReasoning: false
            }))
          } else if (r.value === 'show') {
            patchUiState(state => ({
              ...state,
              sections: { ...state.sections, thinking: 'expanded' },
              showReasoning: true
            }))
          }

          ctx.transcript.sys(t('slashCmd.session.reasoning.current', r.value))
        })
      )
    }
  },

  {
    help: 'toggle fast mode [normal|fast|ultrafast|status|on|off|toggle]',
    name: 'fast',
    run: (arg, ctx) => {
      const mode = arg.trim().toLowerCase()
      const valid = new Set(['', 'status', 'normal', 'fast', 'ultrafast', 'on', 'off', 'toggle'])

      if (!valid.has(mode)) {
        return ctx.transcript.sys(t('slashCmd.session.fast.usage'))
      }

      if (!mode || mode === 'status') {
        return ctx.gateway
          .rpc<ConfigGetValueResponse>('config.get', { key: 'fast', session_id: ctx.sid })
          .then(
            ctx.guarded<ConfigGetValueResponse>(r =>
              ctx.transcript.sys(t('slashCmd.session.fast.mode', fastModeWord(r.value)))
            )
          )
          .catch(ctx.guardedErr)
      }

      ctx.gateway
        .rpc<ConfigSetResponse>('config.set', { key: 'fast', session_id: ctx.sid, value: mode })
        .then(
          ctx.guarded<ConfigSetResponse>(r => {
            const next = fastModeWord(r.value)
            ctx.transcript.sys(t('slashCmd.session.fast.mode', next))
            patchUiState(state => ({
              ...state,
              info: state.info
                ? {
                    ...state.info,
                    fast: next !== 'normal',
                    service_tier: { fast: 'priority', normal: '', ultrafast: 'ultrafast' }[next]
                  }
                : state.info
            }))
          })
        )
        .catch(ctx.guardedErr)
    }
  },

  {
    help: 'control busy enter mode [queue|steer|interrupt|status]',
    name: 'busy',
    run: (arg, ctx) => {
      const mode = arg.trim().toLowerCase()
      const valid = new Set(['', 'status', 'queue', 'steer', 'interrupt'])

      if (!valid.has(mode)) {
        return ctx.transcript.sys(t('slashCmd.session.busy.usage'))
      }

      if (!mode || mode === 'status') {
        return ctx.gateway
          .rpc<ConfigGetValueResponse>('config.get', {
            key: 'busy',
            ...(ctx.gateway.gw.isCanonical ? { session_id: ctx.sid } : {})
          })
          .then(
            ctx.guarded<ConfigGetValueResponse>(r => {
              const current = r.value || 'interrupt'
              ctx.transcript.sys(t('slashCmd.session.busy.mode', current))
            })
          )
          .catch(ctx.guardedErr)
      }

      ctx.gateway
        .rpc<ConfigSetResponse>('config.set', {
          key: 'busy',
          value: mode,
          ...(ctx.gateway.gw.isCanonical ? { session_id: ctx.sid } : {})
        })
        .then(
          ctx.guarded<ConfigSetResponse>(r => {
            const next = r.value || mode

            if (next === 'queue' || next === 'steer' || next === 'interrupt') {
              patchUiState({ busyInputMode: next })
            }

            ctx.transcript.sys(t('slashCmd.session.busy.mode', next))
          })
        )
        .catch(ctx.guardedErr)
    }
  },

  {
    help: 'cycle verbose tool-output mode (updates live agent)',
    name: 'verbose',
    run: (arg, ctx) => {
      ctx.gateway
        .rpc<ConfigSetResponse>('config.set', { key: 'verbose', session_id: ctx.sid, value: arg || 'cycle' })
        .then(
          ctx.guarded<ConfigSetResponse>(
            r => r.value && ctx.transcript.sys(t('slashCmd.session.verbose.current', r.value))
          )
        )
    }
  },

  {
    help: 'session usage + Nous credits',
    name: 'usage',
    run: (_arg, ctx) => {
      if (ctx.sid && ctx.gateway.gw?.isCanonical) {
        ctx.ran?.()

        return canonicalUsage(ctx)
      }

      // health: allow CC -- /usage: appends the plan allowance bars from billing.state for usage-window plans only; this handler was over target on main
      ctx.gateway.rpc<SessionUsageResponse>('session.usage', { session_id: ctx.sid }).then(r => {
        if (ctx.stale()) {
          return
        }

        const sys = ctx.transcript.sys

        if (r) {
          patchUiState({
            usage: { calls: r.calls ?? 0, input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0 }
          })
        }

        // Nous balance block is agent-independent (a portal fetch), so it shows
        // even with zero API calls or on a resumed session. Prefer the shared
        // dollar usage model (two-bar view, dollars-only); fall back to the
        // legacy text lines only when the model is unavailable.
        const usageModel = r?.usage
        const barLines = usageBarsText(usageModel)
        let showedBalance = false

        if (usageModel?.available && (barLines.length || usageModel.status === 'free')) {
          const sections: PanelSection[] = []

          const plan =
            usageModel.plan_name ?? (usageModel.status === 'free' ? t('slashCmd.session.usage.freePlan') : null)

          if (plan) {
            sections.push({
              text: usageModel.renews_display
                ? t('slashCmd.session.usage.planRenews', plan, usageModel.renews_display)
                : t('slashCmd.session.usage.plan', plan)
            })
          }

          if (barLines.length) {
            sections.push({ text: barLines.join('\n') })
          }

          if (usageModel.status === 'free') {
            sections.push({ text: t('slashCmd.session.usage.freeNote') })
          } else if (usageModel.status === 'low') {
            sections.push({
              text: t(
                'slashCmd.session.usage.lowNote',
                usageModel.total_spendable_display ?? t('slashCmd.session.usage.lowBalanceFallback')
              )
            })
          }

          ctx.transcript.panel(t('slashCmd.session.usage.balanceTitle'), sections)
          showedBalance = true
        } else {
          const creditsLines = r?.credits_lines ?? []

          if (creditsLines.length && r?.credits_usage_window) {
            // Usage-window plan: plain stacked bar lines (a panel would re-wrap them).
            const windowLines = creditsLines
              .map(line => stripAnsi(String(line)).trimEnd())
              .filter(line => line.length > 0)

            for (const line of windowLines) {
              sys(line)
            }

            showedBalance = windowLines.length > 0
          } else if (creditsLines.length) {
            ctx.transcript.panel(t('slashCmd.session.usage.nousBalanceTitle'), [{ text: creditsLines.join('\n') }])
            showedBalance = true
          }
        }

        if (!r?.calls) {
          if (!showedBalance) {
            sys(t('slashCmd.session.usage.noCalls'))
          }

          sys(t('slashCmd.session.usage.cta'))

          return
        }

        ctx.transcript.panel(t('slashCmd.session.usage.usageTitle'), usageTotalsSections(r))

        sys(t('slashCmd.session.usage.cta'))
      })
    }
  }
]
