From cb53b4b98f09b27f7c9e55b7a6facb667e5eb1c5 Mon Sep 17 00:00:00 2001 From: code-agent Date: Sat, 1 Jan 2000 00:00:00 +0000 Subject: [PATCH] code-agent: implement issue 2 Approval-Correlation: 49036599e5df8e265ad6146109634e40 Plan-SHA256: 8b8371814cb3da5d7a9f2136053f543d1f524824ce1d3f1fe307cfe56c583784 Base-SHA: d2f8326a1a5e247b28330652742e41d2357eead3 --- README.md | 23 +++++++ docs/data-model.md | 13 ++++ docs/interface-contract.md | 24 +++++++ docs/project-structure.md | 23 +++++++ docs/requirements/req-001_project-scaffold.md | 26 ++++++++ docs/runtime-flow.md | 13 ++++ pyproject.toml | 28 ++++++++ src/chat_tui/__init__.py | 4 ++ src/chat_tui/__main__.py | 7 ++ src/chat_tui/app.py | 66 +++++++++++++++++++ tests/test_app.py | 46 +++++++++++++ 11 files changed, 273 insertions(+) create mode 100644 docs/data-model.md create mode 100644 docs/interface-contract.md create mode 100644 docs/project-structure.md create mode 100644 docs/requirements/req-001_project-scaffold.md create mode 100644 docs/runtime-flow.md create mode 100644 pyproject.toml create mode 100644 src/chat_tui/__init__.py create mode 100644 src/chat_tui/__main__.py create mode 100644 src/chat_tui/app.py create mode 100644 tests/test_app.py diff --git a/README.md b/README.md index 89e2031..ceda212 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,25 @@ # gitea-agent-testbed +Python 3.11 이상에서 실행하는 Textual 기반 대화형 TUI 스캐폴드입니다. 입력한 문자열을 세션 내 대화 영역에 누적 표시하며, LLM이나 외부 API는 호출하지 않습니다. + +## 설치 + +```bash +python -m pip install -e '.[test]' +``` + +## 실행 + +```bash +chat-tui +# 또는 +python -m chat_tui +``` + +입력창에서 메시지를 입력하고 Enter를 누르면 대화 영역에 표시됩니다. `Escape` 또는 `Ctrl+Q`로 종료합니다. + +## 테스트 + +```bash +python -m unittest discover -s tests -v +``` diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..5ee7a46 --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,13 @@ +# 데이터 모델 + +## input-display-state + +초기 구현의 상태는 `ChatApp.submitted_inputs`와 화면 위젯으로 구성한다. + +- `Input.value`: 현재 입력창에 편집 중인 문자열이다. +- `submitted_inputs`: 현재 애플리케이션 세션에서 제출된 문자열의 순서 있는 목록이다. +- `#conversation`: 제출된 각 문자열을 `Label`로 표시하는 세션 내 대화 영역이다. + +입력 제출이 성공하면 원문을 `submitted_inputs`에 추가하고 같은 문자열을 대화 영역에 표시한 뒤 입력창을 비운다. 빈 문자열은 추가하지 않는다. + +이 모델은 프로세스 메모리에만 존재한다. 파일, 데이터베이스, 외부 서비스에는 기록하지 않으며 애플리케이션 종료 시 상태가 폐기된다. LLM 응답이나 메시지 작성자 구분은 초기 범위에 포함하지 않는다. diff --git a/docs/interface-contract.md b/docs/interface-contract.md new file mode 100644 index 0000000..2d55a9e --- /dev/null +++ b/docs/interface-contract.md @@ -0,0 +1,24 @@ +# 인터페이스 계약 + +## tui-input-display + +### 화면 + +- Textual 애플리케이션은 헤더, `#conversation` 대화 영역, `#user-input` 입력창을 제공한다. +- 애플리케이션 시작 시 입력창에 포커스를 둔다. + +### 입력 제출 + +- 사용자가 `#user-input`에서 Enter를 누르면 현재 값이 제출된다. +- 비어 있지 않은 값은 변경 없이 대화 영역에 새 항목으로 추가된다. +- 제출 후 입력창은 빈 값이 된다. +- 대화 영역의 항목 순서는 제출 순서와 같다. + +### 종료 + +- `Escape`와 `Ctrl+Q`는 애플리케이션의 종료 동작을 호출한다. +- 종료 시 세션 내 메모리 상태를 영구 저장하지 않는다. + +### 경계 + +이 인터페이스는 화면 표시와 입력 상태 관리만 담당한다. LLM 클라이언트, 네트워크 요청, 인증 및 외부 서비스 연동은 제공하지 않는다. diff --git a/docs/project-structure.md b/docs/project-structure.md new file mode 100644 index 0000000..83025c7 --- /dev/null +++ b/docs/project-structure.md @@ -0,0 +1,23 @@ +# 프로젝트 구조 + +```text +. +├── pyproject.toml +├── README.md +├── src/ +│ └── chat_tui/ +│ ├── __init__.py +│ ├── __main__.py +│ └── app.py +├── tests/ +│ └── test_app.py +└── docs/ + ├── data-model.md + ├── interface-contract.md + ├── project-structure.md + ├── requirements/ + │ └── req-001_project-scaffold.md + └── runtime-flow.md +``` + +`pyproject.toml`은 Python 3.11 이상, Textual 런타임 의존성, 선택적 테스트 의존성, 콘솔 진입점을 선언한다. `src/chat_tui/app.py`는 Textual 애플리케이션과 실행 함수를 제공하고, `tests/test_app.py`는 입력 표시와 종료 계약을 검증한다. diff --git a/docs/requirements/req-001_project-scaffold.md b/docs/requirements/req-001_project-scaffold.md new file mode 100644 index 0000000..4ba6dde --- /dev/null +++ b/docs/requirements/req-001_project-scaffold.md @@ -0,0 +1,26 @@ +# 프로젝트 스캐폴드 요구사항 + +## 범위 + +Python 3.11 이상과 표준 `pyproject.toml`을 사용하는 Textual 기반 TUI 초기 스캐폴드를 제공한다. + +## 기능 요구사항 + +1. `chat-tui` 콘솔 명령과 `python -m chat_tui` 실행을 지원한다. +2. Textual 화면에 사용자 입력창과 대화 영역을 표시한다. +3. 사용자가 입력을 제출하면 입력한 문자열을 세션 내 대화 영역에 순서대로 누적한다. +4. 제출 뒤 입력창을 비운다. +5. `Escape` 또는 `Ctrl+Q`로 애플리케이션 종료를 요청할 수 있다. +6. 입력 표시와 종료 동작을 자동 테스트로 검증한다. + +## 제외 범위 + +LLM 연동, 응답 생성, 외부 API 호출, 인증, 영구 대화 기록 저장은 구현하지 않는다. + +## 수용 기준 + +- Python 3.11 이상 환경에서 의존성을 설치할 수 있다. +- TUI가 실행되고 입력창에 포커스가 배치된다. +- 두 번 이상 제출한 입력이 제출 순서대로 모두 표시된다. +- 빈 입력은 대화 기록에 추가되지 않는다. +- 자동 테스트가 입력 누적 표시와 종료 요청을 검증한다. diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md new file mode 100644 index 0000000..010ac2e --- /dev/null +++ b/docs/runtime-flow.md @@ -0,0 +1,13 @@ +# 실행 흐름 + +## tui-input-loop + +1. `chat-tui` 또는 `python -m chat_tui`가 `main()`을 호출한다. +2. `ChatApp`이 Textual 이벤트 루프를 시작하고 화면을 구성한다. +3. 입력창에 포커스를 둔 뒤 사용자의 입력을 기다린다. +4. 사용자가 Enter를 누르면 `Input.Submitted` 이벤트를 처리한다. +5. 비어 있지 않은 입력을 세션 목록에 추가하고 같은 값을 대화 영역의 새 `Label`로 마운트한다. +6. 입력창을 비우고 대화 영역을 마지막 항목으로 스크롤한 뒤 다시 입력을 기다린다. +7. 사용자가 `Escape` 또는 `Ctrl+Q`를 누르면 Textual 종료 동작을 호출한다. + +이 흐름에는 LLM 호출이나 외부 네트워크 요청이 없다. 애플리케이션 종료 뒤 세션 목록과 화면 항목은 보존되지 않는다. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6c25db3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "gitea-agent-testbed" +version = "0.1.0" +description = "Textual 기반 대화형 TUI 스캐폴드" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "textual>=0.47,<1.0", +] + +[project.optional-dependencies] +test = [ + "pytest>=8.0", +] + +[project.scripts] +chat-tui = "chat_tui.app:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] diff --git a/src/chat_tui/__init__.py b/src/chat_tui/__init__.py new file mode 100644 index 0000000..1288a33 --- /dev/null +++ b/src/chat_tui/__init__.py @@ -0,0 +1,4 @@ +"""Textual 기반 대화형 TUI 패키지.""" + +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/src/chat_tui/__main__.py b/src/chat_tui/__main__.py new file mode 100644 index 0000000..8d7ab0d --- /dev/null +++ b/src/chat_tui/__main__.py @@ -0,0 +1,7 @@ +"""python -m chat_tui 실행 진입점.""" + +from .app import main + + +if __name__ == "__main__": + main() diff --git a/src/chat_tui/app.py b/src/chat_tui/app.py new file mode 100644 index 0000000..373bb78 --- /dev/null +++ b/src/chat_tui/app.py @@ -0,0 +1,66 @@ +"""사용자 입력을 세션 내 대화 영역에 표시하는 Textual 애플리케이션.""" + +from textual.app import App, ComposeResult +from textual.containers import VerticalScroll +from textual.widgets import Header, Input, Label + + +class ChatApp(App[None]): + """LLM 호출 없이 사용자의 제출 입력만 표시하는 초기 TUI.""" + + CSS = """ + Screen { + layout: vertical; + } + + #conversation { + height: 1fr; + border: round $accent; + padding: 1; + } + + #user-input { + margin: 1 0; + } + """ + + BINDINGS = [ + ("ctrl+q", "quit", "종료"), + ("escape", "quit", "종료"), + ] + + def __init__(self) -> None: + super().__init__() + self.submitted_inputs: list[str] = [] + + def compose(self) -> ComposeResult: + yield Header(show_clock=False) + yield VerticalScroll(id="conversation") + yield Input( + placeholder="메시지를 입력하고 Enter를 누르세요.", + id="user-input", + ) + + def on_mount(self) -> None: + self.query_one("#user-input", Input).focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + """제출된 입력을 기록하고 대화 영역에 누적 표시한다.""" + value = event.value + if not value: + return + + self.submitted_inputs.append(value) + conversation = self.query_one("#conversation", VerticalScroll) + conversation.mount(Label(value, classes="user-message")) + event.input.value = "" + conversation.scroll_end(animate=False) + + +def main() -> None: + """콘솔 스크립트의 실행 진입점.""" + ChatApp().run() + + +if __name__ == "__main__": + main() diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..5072f5c --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,46 @@ +import unittest +from unittest.mock import patch + +from textual.widgets import Input, Label + +from chat_tui.app import ChatApp + + +class ChatAppTest(unittest.IsolatedAsyncioTestCase): + async def test_submitted_input_is_displayed_and_accumulated(self) -> None: + app = ChatApp() + async with app.run_test() as pilot: + input_widget = app.query_one("#user-input", Input) + input_widget.value = "첫 번째 입력" + await pilot.press("enter") + await pilot.pause() + + input_widget.value = "두 번째 입력" + await pilot.press("enter") + await pilot.pause() + + self.assertEqual( + app.submitted_inputs, + ["첫 번째 입력", "두 번째 입력"], + ) + displayed = [label.renderable for label in app.query("#conversation Label")] + self.assertEqual(displayed, ["첫 번째 입력", "두 번째 입력"]) + self.assertEqual(input_widget.value, "") + + async def test_empty_input_is_not_added(self) -> None: + app = ChatApp() + async with app.run_test() as pilot: + await pilot.press("enter") + await pilot.pause() + + self.assertEqual(app.submitted_inputs, []) + self.assertEqual(len(app.query("#conversation Label")), 0) + + async def test_escape_requests_application_exit(self) -> None: + app = ChatApp() + with patch.object(app, "exit") as exit_mock: + async with app.run_test() as pilot: + await pilot.press("escape") + await pilot.pause() + + exit_mock.assert_called_once_with() -- 2.54.0