Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

キーボードとマウスってカスだよな

俺は常々 「キーボードとマウスという組み合わせはインターフェイスの到達点ではない」 と主張していた。が、別に代替案があるわけでもなく、また、その組み合わせが長い歴史で大幅な変化を遂げなかったことからも、しばらくブレイクスルーは起きないんだろうなと諦念していた。

が、ここ数年の変化を見て、「実はあと十数年したらキーボードとマウスは代替されるんじゃないのか?」と思い始めた。AIだ。

AI によってアプリは固定のカタチを持たなくなる

AI によってコーディング能力というものは飛躍的に進歩した。claude codeが代表的ではあるが、ここで注目したいのは claudeのArtifacts。 Artifactsはブラウザで即動かせる状態のアプリを対話で作る機能で、つまり アプリ開発が自然言語のみでほぼ完結する

もちろん、複雑なドメイン知識を要するような機能を作れるようにはならないだろう。しかし、「自分でクライアントをカスタマイズする」時代はそう遠くない。特にコンテンツ投稿型サービスなんかは、そのアプリデザインを固定のものにとどめる必要がなくなっていく はずだ。

インターフェイスを自分で定義する時代

最初の話に戻ろう。AIによってアプリデザインをユーザー自身が定義可能になった場合、それはマウスやキーボード操作の最小化を誘因する可能性が高い。「5クリック必要な操作を1クリックで済むようにアプリのインターフェイスを改造」すればよいからだ。こうなってきた場合、 最終的にキーボードやマウスは補助的なインターフェイスになり、 主軸となるのはアプリのインターフェイスになる。

プログラミングにおいて既にキーボードは副次的なインターフェイスに成り下がっている

というか、プログラミングでは既にそうなっている。 今までのコーディングは当然ながらキーボードがメインのインターフェイスであった。しかし、今のキーボードはclaude codeなどに指示を出すためのインターフェイスであり、コーディング自体はclaudeが主インターフェイスになっている。 キーボードはやはりコーディングをするための最適なインターフェイスではなかったということだ。

まとめ

なんか書いていて論理の流れがしっちゃかめっちゃかになった気がした(よく読むとだいぶ破綻しているよ、探してみよう)。

まあでも、キーボードって数十年ブレイクスルーがないことに甘んじたインターフェイスだよなぁってず〜〜〜〜〜〜〜〜〜〜〜〜っと思ってたのは事実だし、AIの台頭によって特にコーディング面ではその座からようやく引きずり下ろされつつあるので嬉しいとは思ってる。

まあでも文筆業の人とかは全然そんなことないんだろうな。話聞いてみたい。

neovimのmsgポップアップで古いものを薄くする

neovim0.12でmsgポップアップが入ってきた(まだ実験機能だが)。これを使うと今までコマンドラインに出ていたメッセージをスナックバー的な形で表示することができる。

neovim0.12のmsgポップアップ

しかし、どれが最新なのかひとめでは見づらいという問題がある。そこで、古いものほど薄くなるようにフェードをかけるようにしたい。

フェードさせる

こうすればパッと見てどれが最新のものかわかる

設定方法

-- 古いメッセージほど暗くするfade用のハイライト/描画
local fade_ns = vim.api.nvim_create_namespace('msg_fade')
local function setup_fade_hl(
  local function hl(name, attr)
    return vim.api.nvim_get_hl(0, { name = name, link = false })[attr]
  end
  local fg = hl('Normal', 'fg') or hl('NormalFloat', 'fg')
  local bg = hl('NormalFloat', 'bg') or hl('Normal', 'bg')
  if not (fg and bg) then return end
  for i, alpha in ipairs({ 0.55, 0.35, 0.2 }) do
    local function mix(s)
      local f, b = math.floor(fg / 2 ^ s) % 256, math.floor(bg / 2 ^ s) % 256
      return math.floor(f * alpha + b * (1 - alpha))
    end
    vim.api.nvim_set_hl(0, 'MsgFade' .. i, {
      fg = string.format('#%02x%02x%02x', mix(16), mix(8), mix(0)),
    })
  end
end
setup_fade_hl()
vim.api.nvim_create_autocmd('ColorScheme', { callback = setup_fade_hl })

-- 最終行(最新)はそのまま、上にいくほど MsgFade1→2→3 で暗くする
local function refade(buf)
  if not vim.api.nvim_buf_is_valid(buf) then return end
  local n = vim.api.nvim_buf_line_count(buf)
  vim.api.nvim_buf_clear_namespace(buf, fade_ns, 0, -1)
  for i = 0, n - 1 do
    local age = math.min(n - 1 - i, 3)
    if age >= 1 then
      vim.api.nvim_buf_set_extmark(buf, fade_ns, i, 0, {
        end_row = i + 1,
        hl_group = 'MsgFade' .. age,
        hl_eol = true,
        priority = 250, -- ErrorMsg等の既存色より上にかぶせる
      })
    end
  end
end

-- msg を装飾
vim.api.nvim_create_autocmd('FileType', {
  pattern = 'msg',
  callback = function(ev)
    -- buf変更のたびにfadeを張り直す
    if not vim.b[ev.buf].msg_fade_attached then
      vim.b[ev.buf].msg_fade_attached = true
      vim.api.nvim_buf_attach(ev.buf, false, {
        on_lines = function() vim.schedule(function() refade(ev.buf) end) end,
      })
      refade(ev.buf)
    end
  end,
})

(claudeに頼んだら1分くらいで完成した。neovimカスタマイズが捗るよほんと)

vim.packを使ってlazy.nvimをインストールする

lazy.nvim自体のインストール処理は地味にめんどうです。

lazy.nvim自体をインストールする設定

15行もあるうえ、そこまでわかりやすいとも言えません。

これが、neovim0.12で導入された標準パッケージマネージャーのvim.packを使うとだいぶ簡潔になります。

vim.packでlazy.nvimをインストールする

vim.pack.add({
  { src = 'https://github.com/folke/lazy.nvim.git', version = 'stable' },
}, { load = true })

これだけ。設定の意図も明快ですっきり分かりやすいです。

…まあ、細かい部分で差異がある可能性はあるので、やる場合は自己責任で。今のところはちゃんと動いていそう。

neovim 0.12からはネイティブの補完で十分そう

neovim 0.11でLSP補完が有効になった。が、枠線がなかったりプレビューが不足していたり、実用するにはあと一歩足りてない感 があった。

0.11の補完画面

が、0.12でその辺に 強化が入った。

0.12の補完画面

見た目的にも結構変化がわかりやすい。

0.12で入った強化

neovim docsのNews-0.12に書いてあるこの辺りがポイント。

  • 補完アイテムのプレビュー
  • popup menu にボーダー追加

個人的に大きいのは1つ目。プレビュー(枠線部)があることで 圧倒的に使いやすくなった

2つ目も気分的にはだいぶアガる。枠線が何もないと見づらくて使いづらかったので、けっこう嬉しい。

(他にも色が表示されるようになったという細かい変更もあるが、個人的にはふーんって感じだ)

設定方法

基本はこれでOK。

vim.o.pumborder = 'rounded' -- ポップアップメニューに罫線を追加
vim.opt.completeopt = { 'menu', 'menuone', 'noselect', 'fuzzy', 'popup' } -- popupを入れると候補の説明がプレビューされる

-- LSPの補完を自動で有効化
vim.api.nvim_create_autocmd('LspAttach', {
  callback = function(ev)
    local client = vim.lsp.get_client_by_id(ev.data.client_id)
    if client and client:supports_method('textDocument/completion') then
      vim.lsp.completion.enable(true, client.id, ev.buf, { autotrigger = true })
    end
  end,
})

ただ、これだけだと候補説明ウィンドウにはボーダーがつかない。今はオプションが未整備なので、ハックを使って無理やり罫線をつける↓

-- HACK: ドキュメントポップアップに無理やりボーダーを付ける
-- 現状 winborder や completeopt=popup だけではドキュメントfloatのボーダーを制御できない
-- https://github.com/neovim/neovim/issues/38248
-- 将来的に completepopup オプション等が実装されればこのワークアラウンドは不要になる
local orig_complete_set = vim.api.nvim__complete_set
vim.api.nvim__complete_set = function(...)
  local result = orig_complete_set(...)
  if result and result.winid then
    pcall(vim.api.nvim_win_set_config, result.winid, { border = 'rounded' })
  end
  return result
end

(この手法は deathbeam/autocomplete.nvim や neovim/neovim#29225 でも使われているので、そこまでダーティではない)

実際の設定

まとめ: もうネイティブの補完でいいかも

0.11で使えるようになったLSP補完が0.12でいよいよ実用レベルになったといえる。特段こだわりがなければもうこれで十分そうだ。

tmuxで起動しているclaudeを横断して監視する

./claude-ps で起動。

#!/usr/bin/env bash
set -euo pipefail

# claude-ps: Monitor Claude Code sessions running in tmux panes

HOME_DIR="$HOME"
WATCH_INTERVAL=1

# Colors
BOLD='\033[1m'
GREEN='\033[32m'
YELLOW='\033[33m'
DIM='\033[2m'
RESET='\033[0m'

usage() {
  echo "Usage: claude-ps [-w|--watch [INTERVAL]]"
  echo "  -w, --watch [SEC]  Watch mode (default: ${WATCH_INTERVAL}s interval)"
  exit 0
}

check_deps() {
  if ! command -v tmux &>/dev/null; then
    echo "error: tmux is not installed" >&2
    exit 1
  fi
  if ! tmux list-sessions &>/dev/null 2>&1; then
    echo "error: no tmux sessions found" >&2
    exit 1
  fi
}

parse_args() {
  watch_mode=false
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -w|--watch)
        watch_mode=true
        if [[ "${2:-}" =~ ^[0-9]+$ ]]; then
          WATCH_INTERVAL="$2"
          shift
        fi
        shift
        ;;
      -h|--help)
        usage
        ;;
      *)
        echo "Unknown option: $1" >&2
        usage
        ;;
    esac
  done
}

is_claude_pane() {
  local cmd="$1" title="$2"
  [[ "$cmd" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]] || [[ "$title" == *"Claude Code"* ]]
}

# Detect status from pane_title icon
# ✳ (e29cb3) / ✻ (e29cbb) = idle, Braille spinner (e2a080-e2a3bf) = running
detect_claude_status() {
  local title="$1"
  local icon_hex
  icon_hex=$(echo -n "$title" | head -c 3 | xxd -p)

  if [[ "$icon_hex" == "e29cb3" || "$icon_hex" == "e29cbb" ]]; then
    echo "idle"
  elif [[ "$icon_hex" == e2a0* || "$icon_hex" == e2a1* || "$icon_hex" == e2a2* || "$icon_hex" == e2a3* ]]; then
    echo "running"
  else
    echo "unknown"
  fi
}

extract_task() {
  local title="$1"
  local task
  task=$(echo "$title" | sed $'s/^[\xe2\xa0\x80-\xe2\xa3\xbf\xe2\x9c\xb3\xe2\x9c\xbb\xe2\x8f\xb5\xe2\x8f\xb6\xe2\x8f\xb7\xe2\x8f\xb8] *//')
  task="${task#Claude Code}"
  task="${task# - }"
  if [[ ${#task} -gt 45 ]]; then
    task="${task:0:42}..."
  fi
  echo "$task"
}

# List Claude panes as pipe-delimited lines: dir|status|task
get_list_claude_panes() {
  while IFS='|' read -r _pane cmd dir title; do
    if ! is_claude_pane "$cmd" "$title"; then
      continue
    fi
    local status short_dir task
    status=$(detect_claude_status "$title")
    short_dir="$(basename "$dir")"
    task=$(extract_task "$title")
    echo "${status}|${task}|${short_dir}"
  done < <(tmux list-panes -a -F "#{session_name}:#{window_index}.#{pane_index}|#{pane_current_command}|#{pane_current_path}|#{pane_title}")
}

render() {
  # header
  printf "${BOLD}  %-45s %s${RESET}\n" "TASK" "DIR"
  printf "%s\n" "$(printf '%.0s─' {1..60})"

  # panes
  local found=0
  while IFS='|' read -r status task short_dir; do
    found=$((found + 1))
    local status_icon status_color
    case "$status" in
      running) status_icon=""; status_color="$GREEN" ;;
      idle)    status_icon=""; status_color="$YELLOW" ;;
      *)       status_icon="?"; status_color="$DIM" ;;
    esac
    printf "${status_color}%s${RESET}  %-45s ${DIM}%s${RESET}\n" \
      "$status_icon" "$task" "$short_dir"
  done < <(get_list_claude_panes)
  if [[ $found -eq 0 ]]; then
    echo "No Claude Code sessions found in tmux."
  fi

  # footer
  printf "\n${DIM}Updated: $(date '+%H:%M:%S')${RESET}"
}

watch_loop() {
  trap 'tput cnorm; tput clear; exit 0' INT TERM
  tput civis
  tput clear
  while true; do
    local buf
    buf=$(render)
    tput home
    printf "${DIM}claude-ps  (every ${WATCH_INTERVAL}s, Ctrl-C to stop)${RESET}\n\n"
    printf '%s' "$buf"
    tput ed
    sleep "$WATCH_INTERVAL"
  done
}

# --- Main ---

check_deps
parse_args "$@"

if $watch_mode; then
  watch_loop
else
  render
  echo
fi