Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@ codiva/
│ │ └── __fixtures__/ # サニタイズ済み実 SDK メッセージ(reducer テスト用)
│ ├── ui/ # Ink コンポーネント(kebab-case, 識別子は PascalCase)
│ │ ├── index.ts # バレル
│ │ ├── theme.ts # アクセント色・グリフ(Claude Code 風の共通ビジュアル)
│ │ ├── banner.tsx # 起動時ヘッダ(✻ codiva + サブタイトル + cwd, 枠なし)
│ │ ├── session-list.tsx
│ │ ├── session-detail.tsx
│ │ ├── prompt-input.tsx
│ │ ├── prompt-input.tsx # 上下横罫線 + ❯ キャレットの入力欄(presentational)
│ │ ├── status-footer.tsx # ⏵⏵ auto mode on (shift+tab...) のモード行
│ │ ├── permission-dialog.tsx / permission-dialog.spec.tsx
│ │ ├── progress-badge.tsx / progress-badge.spec.tsx
│ │ ├── hooks.ts # useSessions()(useSyncExternalStore)/ useClock()
Expand Down Expand Up @@ -135,18 +138,25 @@ interface SessionState {

### UI (ui/)

- `App`: ビュー状態(`list` | `detail:<id>`)と全体キーバインドを管理。
- `SessionList`: 一覧 + 選択カーソル。`PromptInput` を上部に常設し、いつでも新規投入できる。
- `SessionDetail`: メッセージログ + 追加指示入力 + `PermissionDialog`。ログは Ink の `<Static>` で追記描画し再描画コストを抑える。
Claude Code の実画面に寄せる: 下部に**上下の全幅横罫線だけ**の入力欄(`PromptInput`、角丸枠ではない)、その下にモード行(`StatusFooter` = `⏵⏵ auto mode on (shift+tab to cycle)` + 文脈ヒント)を常設。ヘッダは枠なしのワードマーク。色とグリフは `theme.ts` に集約。

- `App`: ビュー状態(`list` | `detail:<id>`)と全体キーバインドを管理。`cwd` を受け取りバナーへ渡す。
- `Banner`: 起動時ヘッダ(`✻ codiva` + サブタイトル + cwd)。枠なし3行、一覧上部に表示。
- `SessionList`: `Banner` + 一覧 + 選択カーソル + 下部 `PromptInput`/`StatusFooter`。いつでも新規投入できる。
- `SessionDetail`: `<Static>` メッセージログ(`⏺`/`⎿`/`>` のグリフで転記)+ ステータスヘッダ + `PromptInput` または操作パネル/`PermissionDialog` + `StatusFooter`。
- `PromptInput` / `StatusFooter`: presentational。キー処理は各 view の単一 `useInput` に集約(ロジックは持たない)。
- 再描画スロットリング: SessionManager の通知を UI 側で ~100ms にスロットルする。

**ランモード(shift+tab トグル)**: `SessionManager.mode`(`auto` | `confirm`)を全セッション共通で保持し、`shift+tab` で `cycleMode()`。`modePolicy` は tool 実行時に `mode` を読むので、切替は稼働中セッションにも即反映される。`auto` = AskUserQuestion 以外を自動承認、`confirm` = 毎回 allow/deny を求める(→ `awaiting_permission`/一覧に「許可待ち」)。UI は `useRunMode()` で購読し、`StatusFooter` が `⏵⏵ auto mode on` / `⏸ confirm mode on` を表示。

## 多言語対応(i18n)

UI 文字列は日本語/英語を設定で切り替えられる。規約は [.claude/rules/i18n.md](../.claude/rules/i18n.md)。

- **カタログ**: 全 UI 文字列は `core/i18n.ts` の `messages`(`Record<Lang, Messages>`)に集約する(純粋)。
UI にリテラルを直書きせず、`useMessages()`(`ui/i18n-context.tsx` の React コンテキスト)で引く。
純関数(`badgeFor` 等)は `Messages` を引数で受ける。動的差し込み・複数形は型安全な文字列テンプレート関数で持つ。
(`banner` / `footer` グループもここに含む。)
- **設定**: 表示言語は `~/.codiva/config.json`(`{ "language": "ja" | "en" | "auto" }`)に永続化する
(Claude Code の `~/.claude/` と同じユーザーグローバルの流儀)。検証変換は `core/config.ts` の
`toConfig()`、ファイル I/O は `utils/config.ts`(`loadConfig` / `saveConfig`)。
Expand Down
4 changes: 3 additions & 1 deletion src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { MessagesProvider, SessionDetail, SessionList } from '@/ui';

type View = { mode: 'list' } | { mode: 'detail'; id: string };

export const App: FC<{ manager: SessionManager; messages?: Messages }> = ({
export const App: FC<{ manager: SessionManager; cwd?: string; messages?: Messages }> = ({
manager,
cwd,
// 既定は ja。index.tsx が解決済みカタログを注入する。
messages = catalogs.ja,
}) => {
Expand Down Expand Up @@ -34,6 +35,7 @@ export const App: FC<{ manager: SessionManager; messages?: Messages }> = ({
manager={manager}
onOpen={(id) => setView({ mode: 'detail', id })}
onQuit={quit}
cwd={cwd}
/>
)}
</MessagesProvider>
Expand Down
26 changes: 26 additions & 0 deletions src/core/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ export interface Messages {
app: {
remainingWorktrees: (n: number) => string;
};
/** 起動バナー(banner.tsx) */
banner: {
subtitle: string;
};
/** 下部モード行(status-footer.tsx) */
footer: {
autoMode: string;
confirmMode: string;
cycleHint: string;
};
}

const ja: Messages = {
Expand Down Expand Up @@ -120,6 +130,14 @@ const ja: Messages = {
remainingWorktrees: (n) =>
`codiva: ${n} 個の worktree が残っています(作業内容は保持されます):`,
},
banner: {
subtitle: '並列 Claude Code セッションを git worktree 上で実行',
},
footer: {
autoMode: '自動モード',
confirmMode: '確認モード',
cycleHint: '(shift+tab で切替)',
},
};

const en: Messages = {
Expand Down Expand Up @@ -172,6 +190,14 @@ const en: Messages = {
remainingWorktrees: (n) =>
`codiva: ${n} worktree${n === 1 ? '' : 's'} left in place (your work is preserved):`,
},
banner: {
subtitle: 'Parallel Claude Code sessions in git worktrees',
},
footer: {
autoMode: 'auto mode on',
confirmMode: 'confirm mode on',
cycleHint: '(shift+tab to cycle)',
},
};

/** 言語 → カタログ。UI は `messages[lang]` を購読する。 */
Expand Down
26 changes: 26 additions & 0 deletions src/core/session-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,4 +272,30 @@ describe('SessionManager', () => {
expect(manager.activeWorktreePaths()).toEqual([]);
});
});

describe('run mode (shift+tab toggle)', () => {
it('defaults to auto', () => {
const { manager } = makeManager();
expect(manager.getMode()).toBe('auto');
});

it('cycleMode toggles auto ⇄ confirm and returns the new mode', () => {
const { manager } = makeManager();
expect(manager.cycleMode()).toBe('confirm');
expect(manager.getMode()).toBe('confirm');
expect(manager.cycleMode()).toBe('auto');
expect(manager.getMode()).toBe('auto');
});

it('notifies subscribers without rebuilding the session snapshot', () => {
const { manager } = makeManager();
const listener = vi.fn();
manager.subscribe(listener);
const before = manager.getSnapshot();
manager.cycleMode();
expect(listener).toHaveBeenCalledTimes(1);
// Sessions did not change, so their snapshot array keeps identity.
expect(manager.getSnapshot()).toBe(before);
});
});
});
39 changes: 38 additions & 1 deletion src/core/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ export interface ActionResult {
error?: string;
}

/**
* Global tool-approval mode, toggled with shift+tab (à la Claude Code).
* - `auto`: run every tool automatically (only AskUserQuestion pauses).
* - `confirm`: pause on every tool for an explicit allow/deny.
* The mode is read at each tool call, so toggling affects live sessions too.
*/
export type RunMode = 'auto' | 'confirm';

export interface SessionManagerDeps {
worktrees: WorktreeService;
queryFn: QueryFn;
Expand Down Expand Up @@ -64,6 +72,7 @@ export class SessionManager {
private readonly usedSlugs = new Set<string>();
private snapshot: SessionState[] = [];
private seq = 0;
private mode: RunMode = 'auto';
private readonly now: () => number;

constructor(private readonly deps: SessionManagerDeps) {
Expand All @@ -77,6 +86,30 @@ export class SessionManager {
};
}

/** Current tool-approval mode (drives the shift+tab footer indicator). */
getMode(): RunMode {
return this.mode;
}

/** Flip auto ⇄ confirm and notify subscribers so the footer re-renders. */
cycleMode(): RunMode {
this.mode = this.mode === 'auto' ? 'confirm' : 'auto';
this.notify();
return this.mode;
}

/**
* Policy applied to sessions that don't get an explicit one. Reads `this.mode`
* at call time so a shift+tab toggle takes effect on already-running sessions.
* AskUserQuestion always escalates — it *is* the ask-the-user channel.
*/
private readonly modePolicy: PermissionPolicy = (toolName) => {
if (toolName === 'AskUserQuestion') {
return 'ask';
}
return this.mode === 'auto' ? 'allow' : 'ask';
};

getSnapshot(): SessionState[] {
return this.snapshot;
}
Expand Down Expand Up @@ -133,7 +166,7 @@ export class SessionManager {
input,
model: this.deps.model,
now: this.now,
policy: this.deps.policy,
policy: this.deps.policy ?? this.modePolicy,
onChange: (s) => this.onSessionChange(id, s),
});
this.sessions.set(id, session);
Expand All @@ -156,6 +189,10 @@ export class SessionManager {

private rebuild(): void {
this.snapshot = this.order.map((id) => this.states.get(id) as SessionState);
this.notify();
}

private notify(): void {
for (const listener of this.listeners) {
listener();
}
Expand Down
4 changes: 3 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ async function main(): Promise<void> {
queryFn: query,
});

const { waitUntilExit } = render(<App manager={manager} messages={t} />, { exitOnCtrlC: false });
const { waitUntilExit } = render(<App manager={manager} cwd={repoRoot} messages={t} />, {
exitOnCtrlC: false,
});
await waitUntilExit();

// Sessions are aborted on quit but their worktrees are intentionally kept so
Expand Down
74 changes: 74 additions & 0 deletions src/ui/banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Box, Text } from 'ink';
import type { FC } from 'react';
import { useMessages } from './i18n-context';
import { glyph, theme } from './theme';

// codiva mascot. Each glyph is rendered in its own <Text>, so you can paint it
// one character at a time via paint() below.
const LOGO = [
' ▄ ▄▄▄▄▄▄▄ ▄',
' █▒██▓▓█████▒█',
'██▒▓▓▀▓▓▓▀▓▓▒██',
'██ ▓ █ ▀ █ ▓ ██',
'██ ▓▒▒ ▒▒▓ ██',
'▀ ▀▀▀▀▀▀▀ ▀',
];

/**
* Per-character painter — return an Ink color (named / '#hex' / 'rgb(r,g,b)') for
* the glyph at (row, col), or undefined for the terminal default. Paint however
* you like; the example below shades by glyph and tints the two eyes:
* - by position (a single cell): `if (row === 3 && col === 5) return 'cyan'`
* - by glyph/shade: switch on `ch` ('█' darkest → '▒' lightest)
* - by line: switch on `row`
*/
function paint(ch: string, row: number, col: number): string | undefined {
if (row === 3 && (col === 5 || col === 9)) return 'cyan'; // eyes
if (ch === '█') return '#ff7847';
if (ch === '▓') return '#ff9d5c';
if (ch === '▒') return '#ffd7a8';
if (ch === '▄' || ch === '▀') return '#e85d2f';
return undefined; // spaces
}

// Precompute cells with stable keys (so JSX keys aren't raw array indices).
const LOGO_ROWS = LOGO.map((line, row) => ({
key: `logo-row-${row}`,
cells: [...line].map((ch, col) => ({ key: `${row}:${col}`, ch, row, col })),
}));

/**
* Borderless startup header echoing Claude Code's banner: the mascot on the left
* and identity / subtitle / cwd on the right (vertically centered against it).
*/
export const Banner: FC<{ cwd?: string; sessionCount: number }> = ({ cwd, sessionCount }) => {
const m = useMessages();
return (
<Box>
<Box flexDirection="column" marginRight={2}>
{LOGO_ROWS.map((r) => (
<Text key={r.key}>
{r.cells.map((c) => (
<Text key={c.key} color={paint(c.ch, c.row, c.col)}>
{c.ch}
</Text>
))}
</Text>
))}
</Box>
<Box flexDirection="column" justifyContent="center">
<Text>
<Text color={theme.accent} bold>
{glyph.star} codiva
</Text>
<Text dimColor>
{' '}
{m.list.sessionCount(sessionCount)}
</Text>
</Text>
<Text dimColor>{m.banner.subtitle}</Text>
{cwd ? <Text dimColor>{cwd}</Text> : null}
</Box>
</Box>
);
};
11 changes: 10 additions & 1 deletion src/ui/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
import type { SessionManager, SessionState } from '@/core';
import type { RunMode, SessionManager, SessionState } from '@/core';

/**
* Subscribe to the manager's snapshot. Notifications are coalesced to ~100ms so
Expand Down Expand Up @@ -29,6 +29,15 @@ export function useSessions(manager: SessionManager): SessionState[] {
);
}

/** Subscribe to the manager's global tool-approval mode (auto ⇄ confirm). */
export function useRunMode(manager: SessionManager): RunMode {
return useSyncExternalStore(
(onChange) => manager.subscribe(onChange),
() => manager.getMode(),
() => manager.getMode(),
);
}

/** A clock that ticks every `ms` so elapsed-time displays stay current. */
export function useClock(ms = 1000): number {
const [now, setNow] = useState(() => Date.now());
Expand Down
3 changes: 3 additions & 0 deletions src/ui/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './banner';
export * from './hooks';
export * from './i18n-context';
export * from './input';
Expand All @@ -6,3 +7,5 @@ export * from './progress-badge';
export * from './prompt-input';
export * from './session-detail';
export * from './session-list';
export * from './status-footer';
export * from './theme';
33 changes: 23 additions & 10 deletions src/ui/prompt-input.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,39 @@
import { Box, Text } from 'ink';
import type { FC } from 'react';
import { glyph, theme } from './theme';

/**
* Presentational one-line input. Key handling lives in the owning view (so there
* is a single useInput per screen); this just renders the buffer and a caret.
* Claude-Code-style input: a full-width horizontal rule above and below a single
* `❯` prompt line (no side borders / corners). Purely presentational — key
* handling lives in the owning view (a single useInput per screen); this just
* renders the buffer and a block caret.
*/
export const PromptInput: FC<{
value: string;
focused: boolean;
placeholder?: string;
label?: string;
}> = ({ value, focused, placeholder = '', label = '›' }) => {
const showPlaceholder = value.length === 0 && !focused;
}> = ({ value, focused, placeholder = '' }) => {
const empty = value.length === 0;
const caret = focused ? <Text inverse> </Text> : null;
return (
<Box>
<Text color="cyan">{label} </Text>
{showPlaceholder ? (
<Text dimColor>{placeholder}</Text>
<Box
borderStyle="single"
borderColor={theme.dim}
borderTop
borderBottom
borderLeft={false}
borderRight={false}
>
<Text color={theme.accent}>{glyph.caret} </Text>
{empty ? (
<Text>
{caret}
<Text dimColor>{placeholder}</Text>
</Text>
) : (
<Text>
{value}
{focused ? <Text inverse> </Text> : null}
{caret}
</Text>
)}
</Box>
Expand Down
Loading
Loading